package net.hurstfrost.hudson.sounds;
import hudson.Extension;
import hudson.Launcher;
import hudson.model.AbstractBuild;
import hudson.model.AbstractProject;
import hudson.model.BuildListener;
import hudson.model.Result;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.BuildStepMonitor;
import hudson.tasks.Notifier;
import hudson.tasks.Publisher;
import hudson.util.FormValidation;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;
import javax.sound.sampled.DataLine.Info;
import net.hurstfrost.hudson.sounds.HudsonSoundsNotifier.HudsonSoundsDescriptor.SoundBite;
import net.sf.json.JSONObject;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerRequest;
/**
* {@link Notifier} that allows Hudson to play audio clips as build notifications..
*
* @author Edward Hurst-Frost
*/
public class HudsonSoundsNotifier extends Notifier {
public static class SoundEvent {
private final String soundId;
private final Result toResult;
private final Set<Result> fromResults;
@DataBoundConstructor
public SoundEvent(@SuppressWarnings("hiding") final String soundId, @SuppressWarnings("hiding") final String toResult, final boolean fromNotBuilt, final boolean fromAborted, final boolean fromFailure, final boolean fromUnstable, final boolean fromSuccess) {
this.soundId = soundId;
Result result = toResult!=null?Result.fromString(toResult):null;
// Must be an exact match to Result, not default to Result.FAILURE.
if (result != null && !result.toString().equals(toResult)) {
result = null;
}
this.toResult = result;
fromResults = new HashSet<Result>();
setFromNotBuilt(fromNotBuilt);
setFromAborted(fromAborted);
setFromFailure(fromFailure);
setFromUnstable(fromUnstable);
setFromSuccess(fromSuccess);
}
public String getSoundId() {
return soundId;
}
public Result getToBuildResult() {
return toResult;
}
public boolean isFromNotBuilt() {
return fromResults.contains(Result.NOT_BUILT);
}
public void setFromNotBuilt(boolean b) {
if (b) {
fromResults.add(Result.NOT_BUILT);
} else {
fromResults.remove(Result.NOT_BUILT);
}
}
public boolean isFromAborted() {
return fromResults.contains(Result.ABORTED);
}
public void setFromAborted(boolean b) {
if (b) {
fromResults.add(Result.ABORTED);
} else {
fromResults.remove(Result.ABORTED);
}
}
public boolean isFromFailure() {
return fromResults.contains(Result.FAILURE);
}
public void setFromFailure(boolean b) {
if (b) {
fromResults.add(Result.FAILURE);
} else {
fromResults.remove(Result.FAILURE);
}
}
public boolean isFromUnstable() {
return fromResults.contains(Result.UNSTABLE);
}
public void setFromUnstable(boolean b) {
if (b) {
fromResults.add(Result.UNSTABLE);
} else {
fromResults.remove(Result.UNSTABLE);
}
}
public boolean isFromSuccess() {
return fromResults.contains(Result.SUCCESS);
}
public void setFromSuccess(boolean b) {
if (b) {
fromResults.add(Result.SUCCESS);
} else {
fromResults.remove(Result.SUCCESS);
}
}
public Set<Result> getFromResults() {
return fromResults;
}
}
private List<SoundEvent> soundEvents;
@DataBoundConstructor
public HudsonSoundsNotifier() {
// Default constructor
}
public BuildStepMonitor getRequiredMonitorService() {
return BuildStepMonitor.STEP;
}
@Override
public HudsonSoundsDescriptor getDescriptor() {
return (HudsonSoundsDescriptor) super.getDescriptor();
}
@Override
public boolean perform(final AbstractBuild<?, ?> build, final Launcher launcher, final BuildListener listener) {
SoundEvent event = getSoundEventFor(build.getResult(), build.getPreviousBuild()!=null?build.getPreviousBuild().getResult():null);
if (event != null) {
try {
getDescriptor().playSound(event.getSoundId());
} catch (UnplayableSoundBiteException e) {
listener.getLogger().println("Failed to play sound '" + event.getSoundId() + "' : " + e.toString());
}
}
return true;
}
@Extension
public static final class HudsonSoundsDescriptor extends BuildStepDescriptor<Publisher> {
private static final String INTERNAL_ARCHIVE = HudsonSoundsNotifier.class.getResource("/sound-archive.zip").toString();
private String soundArchive = INTERNAL_ARCHIVE;
private transient TreeMap<String, SoundBite> sounds;
private transient boolean needsReindex;
public HudsonSoundsDescriptor() {
load();
needsReindex = true;
}
public List<SoundBite> getSounds() {
checkIndex();
return new ArrayList<SoundBite>(sounds.values());
}
private void checkIndex() {
if (needsReindex) {
needsReindex = false;
sounds = rebuildSoundsIndex(soundArchive);
}
}
public SoundBite getSound(String id) {
checkIndex();
if (sounds != null && id != null) {
return sounds.get(id);
}
return null;
}
protected static TreeMap<String, SoundBite> rebuildSoundsIndex(String urlString) {
final TreeMap<String, SoundBite> index = new TreeMap<String, SoundBite>();
try {
URL url = new URL(urlString);
URLConnection connection = url.openConnection();
ZipInputStream zipInputStream = new ZipInputStream(connection.getInputStream());
try {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
if (!entry.isDirectory()) {
final String id = getBiteName(entry.getName());
AudioFileFormat f = null;
try {
f = AudioSystem.getAudioFileFormat(new BufferedInputStream(zipInputStream));
} catch (UnsupportedAudioFileException e) {
// Oh well
}
index.put(id, new SoundBite(id, entry.getName(), urlString, f));
}
}
} finally {
IOUtils.closeQuietly(zipInputStream);
}
} catch (Exception e) {
// Can't find archive (this would have already been notified by doCheckSoundArchive() )
}
return index;
}
protected static String getBiteName(String name) {
int slash = name.lastIndexOf('/');
if (slash != -1) {
name = name.substring(slash + 1);
}
int dot = name.lastIndexOf('.');
if (dot != -1) {
name = name.substring(0, dot);
}
return name;
}
public String getSoundArchive() {
return soundArchive;
}
public void setSoundArchive(String archive) {
if (!StringUtils.isEmpty(archive)) {
soundArchive = toUri(archive);
} else {
soundArchive = INTERNAL_ARCHIVE;
}
// Force index rebuild on next call
needsReindex = true;
sounds = null;
}
/**
* @param archive
* @return
*/
private String toUri(String archive) {
if (archive.startsWith("http://") || archive.startsWith("file:/")) {
return archive;
}
// Try to make sense of this as a filing system path
return new File(archive).toURI().toString();
}
@Override
public boolean isApplicable(Class<? extends AbstractProject> jobType) {
return true;
}
@Override
public boolean configure(final StaplerRequest req, JSONObject json) {
setSoundArchive(json.optString("soundArchive"));
save();
return true;
}
@Override
public String getDisplayName() {
return "Hudson Sounds";
}
@Override
public HudsonSoundsNotifier newInstance(StaplerRequest req, JSONObject formData) {
HudsonSoundsNotifier m = new HudsonSoundsNotifier();
m.setSoundEvents(req.bindJSONToList(SoundEvent.class, formData.get("soundEvents")));
return m;
}
public FormValidation doTestSound(@QueryParameter String selectedSound) {
if (StringUtils.isEmpty(selectedSound)) {
return FormValidation.error("Please choose a sound to test.");
}
try {
playSound(selectedSound);
} catch (UnplayableSoundBiteException e) {
return FormValidation.error("Failed to make sound '" + selectedSound + "' : " + e.toString());
}
return FormValidation.ok("Hudson made sound '" + selectedSound + "' successfully.");
}
public FormValidation doCheckSoundArchive(@QueryParameter final String value) {
URI uri;
try {
uri = new URI(toUri(value));
} catch (URISyntaxException e) {
return FormValidation.warning("The URL '" + value + "' is invalid (" + e.toString() + ")");
}
if (uri.getScheme().equals("file")) {
if (new File(uri).exists()) {
return FormValidation.ok();
}
return FormValidation.warning("File not found '" + uri + "'");
} else if (uri.getScheme().equals("http")) {
try {
URLConnection openConnection = uri.toURL().openConnection();
if (openConnection instanceof HttpURLConnection) {
HttpURLConnection httpUrlConnection = (HttpURLConnection) openConnection;
final int responseCode = httpUrlConnection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
return FormValidation.ok();
}
return FormValidation.warning("The URL '" + value + "' is invalid (" + httpUrlConnection.getResponseMessage() + ")");
}
return FormValidation.warning("The URL '" + value + "' is invalid");
} catch (IOException e) {
return FormValidation.warning("The URL '" + value + "' is invalid (" + e.toString() + ")");
}
} else {
// Invalid URI
return FormValidation.warning("The URI '" + value + "' is invalid");
}
}
public static class SoundBite {
public final String id;
public final String entryName;
public final String url;
public final AudioFileFormat format;
public SoundBite(final String _id, final String _entryName, final String _url, final AudioFileFormat _format) {
id = _id;
entryName = _entryName;
url = _url;
format = _format;
}
public String getId() {
return id;
}
public String getEntryName() {
return entryName;
}
public String getUrl() {
return url;
}
public AudioFileFormat getFormat() {
return format;
}
public String getDescription() {
if (format == null) {
return id + " (unsupported format)";
}
return id + " (" + format.getType() + ")";
}
@Override
public String toString() {
return getDescription();
}
}
protected void playSound(String id) throws UnplayableSoundBiteException {
SoundBite soundBite = getSound(id);
if (soundBite != null) {
try {
URL url = new URL(soundBite.url);
URLConnection connection = url.openConnection();
ZipInputStream zipInputStream = new ZipInputStream(connection.getInputStream());
try {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
if (!entry.getName().equals(soundBite.entryName)) {
continue;
}
final BufferedInputStream stream = new BufferedInputStream(zipInputStream);
playSoundBite(AudioSystem.getAudioInputStream(stream));
return;
}
} finally {
IOUtils.closeQuietly(zipInputStream);
}
} catch (Exception e) {
throw new UnplayableSoundBiteException(soundBite, e);
}
}
throw new UnplayableSoundBiteException("No such sound.");
}
protected void playSoundBite(AudioInputStream audioInputStream) throws LineUnavailableException, IOException {
Info info = new DataLine.Info(SourceDataLine.class, audioInputStream.getFormat());
SourceDataLine line = (SourceDataLine) AudioSystem.getLine(info);
line.open();
line.start();
byte[] buffer = new byte[8096];
while (true) {
int read = audioInputStream.read(buffer);
if (read <= 0) break;
line.write(buffer, 0, read);
}
line.drain();
line.close();
}
}
@SuppressWarnings("serial")
public static class UnplayableSoundBiteException extends Exception {
private final SoundBite soundBite;
public SoundBite getSoundBite() {
return soundBite;
}
public UnplayableSoundBiteException(SoundBite bite, Exception e) {
super(e);
soundBite = bite;
}
public UnplayableSoundBiteException(String message) {
super(message);
soundBite = null;
}
}
public List<SoundEvent> getSoundEvents() {
return soundEvents;
}
public void setSoundEvents(List<SoundEvent> newSounds) {
ArrayList<SoundEvent> validatedList = new ArrayList<SoundEvent>();
for (SoundEvent sound : newSounds) {
if (sound.toResult != null && sound.getSoundId() != null && !sound.getFromResults().isEmpty()) {
validatedList.add(sound);
}
}
this.soundEvents = validatedList;
}
public SoundEvent getSoundEventFor(Result result, Result previousResult) {
if (CollectionUtils.isEmpty(soundEvents)) {
return null;
}
SoundEvent foundEvent = null;
for (SoundEvent event : soundEvents) {
if (event.toResult.equals(result)) {
if (!CollectionUtils.isEmpty(event.fromResults) && event.fromResults.contains(previousResult!=null?previousResult:Result.NOT_BUILT)) {
foundEvent = event;
if (getDescriptor().getSound(foundEvent.getSoundId()) != null) {
break;
}
// Keep looking for valid sound ID
}
}
}
return foundEvent;
}
}