diff --git a/.gitignore b/.gitignore
index 4a60582..283198c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
*.iml
.idea
+
+target
diff --git a/pom.xml b/pom.xml
index 7f33117..d855115 100644
--- a/pom.xml
+++ b/pom.xml
@@ -8,5 +8,39 @@
kensa
1.0-SNAPSHOT
+
+ 1.8
+ 1.8
+
+
+
+ jcenter
+ http://jcenter.bintray.com
+
+
+ jitpack.io
+ https://jitpack.io
+
+
+
+
+
+ com.github.austinv11
+ Discord4j
+ 2.5.2
+
+
+
+ org.slf4j
+ slf4j-log4j12
+ 1.7.21
+
+
+
+ org.apache.commons
+ commons-io
+ 1.3.2
+
+
\ No newline at end of file
diff --git a/src/main/java/com/github/langebangen/kensa/Action.java b/src/main/java/com/github/langebangen/kensa/Action.java
new file mode 100644
index 0000000..677ee6d
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/Action.java
@@ -0,0 +1,56 @@
+package com.github.langebangen.kensa;
+
+/**
+ * @author langen
+ */
+public enum Action
+{
+ HELP ("help", "Shows this help description."),
+ JOIN ("join", "Joins the specified voice channel."),
+ LEAVE ("leave", "Leaves the current channel Kensa is in."),
+ PLAY ("play", "Queues the specified song in the playlist from the specified URL. This play function supports youtube links and urls that ends with .mp3, .ogg, .flac, or .wav."),
+ SKIP ("skip", "Skips the current song and additional future songs if a number is provided."),
+ SONG ("song", "Shows the current track."),
+ PLAYLIST ("playlist", "Shows the playlist."),
+ CLEAR ("clear", "Clears the playlist.");
+
+ private final String action;
+ private final int argsAmount;
+ private final String description;
+
+ Action(String command, String description)
+ {
+ this(command, 0, description);
+ }
+
+ Action(String command, int argsAmount, String description)
+ {
+ this.action = "!" + command;
+ this.argsAmount = argsAmount;
+ this.description = description;
+ }
+
+ public String getAction()
+ {
+ return action;
+ }
+
+ public String getDescription()
+ {
+ return description;
+ }
+
+ public static Action getAction(String commandValue)
+ {
+ for(Action command : values())
+ {
+ if(command.action.equalsIgnoreCase(commandValue))
+ {
+ return command;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/src/main/java/com/github/langebangen/kensa/AudioStreamer.java b/src/main/java/com/github/langebangen/kensa/AudioStreamer.java
new file mode 100644
index 0000000..7a177a6
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/AudioStreamer.java
@@ -0,0 +1,232 @@
+package com.github.langebangen.kensa;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.commons.io.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sx.blah.discord.util.DiscordException;
+import sx.blah.discord.util.MessageBuilder;
+import sx.blah.discord.util.MissingPermissionsException;
+import sx.blah.discord.util.RateLimitException;
+import sx.blah.discord.util.audio.AudioPlayer;
+import sx.blah.discord.util.audio.providers.URLProvider;
+
+import javax.sound.sampled.AudioSystem;
+import javax.sound.sampled.UnsupportedAudioFileException;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Scanner;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author langen
+ */
+public class AudioStreamer
+{
+ private static final Logger logger = LoggerFactory.getLogger(AudioStreamer.class);
+
+ public static void stream(String urlString, AudioPlayer player, MessageBuilder messageBuilder)
+ {
+ if(urlString.contains("youtube.com") || urlString.contains("youtu.be"))
+ {
+ try
+ {
+ streamYoutube(urlString, player, messageBuilder);
+ }
+ catch(UnsupportedAudioFileException | DiscordException | IOException | RateLimitException | MissingPermissionsException e)
+ {
+ logger.error("Error when streaming content from youtube.", e);
+ }
+ }
+ else
+ {
+ try
+ {
+ URL url = new URL(urlString);
+ ExtendedTrack track = new ExtendedTrack(new URLProvider(url),
+ TrackSource.URL, url.toString(), null, null);
+ player.queue(track);
+ sendPlayMessage(track, messageBuilder);
+ }
+ catch(UnsupportedAudioFileException | IOException e)
+ {
+ logger.error("Error when streaming content from url", e);
+ }
+ }
+ }
+
+ private static void streamYoutube(String url, AudioPlayer player, MessageBuilder messageBuilder)
+ throws MissingPermissionsException, IOException, RateLimitException, DiscordException, UnsupportedAudioFileException
+ {
+ File resourceFolder = new File("src/main/resources").getAbsoluteFile();
+ String youtubeDlPath = new File(resourceFolder, "youtube-dl.exe").getPath();
+ String ffmpegPath = new File(resourceFolder, "ffmpeg.exe").getPath();
+ final String[] title = new String[1];
+ final String[] readableDuration = new String[1];
+ new Thread(() ->
+ {
+ ProcessBuilder info = new ProcessBuilder(
+ youtubeDlPath,
+ "-q", //quiet. No standard out.
+ "-j", //Print JSON
+ "--flat-playlist", //Get ONLY the urls of the playlist if this is a playlist.
+ "--ignore-errors",
+ "--skip-download",
+ "--", url
+ );
+
+ byte[] infoData = new byte[0];
+ try
+ {
+ Process infoProcess = info.start();
+ infoData = IOUtils.toByteArray(infoProcess.getInputStream());
+ }
+ catch(IOException e)
+ {
+ e.printStackTrace();
+ }
+ if(infoData == null || infoData.length == 0)
+ {
+ throw new NullPointerException("The youtube-dl info process returned no data!");
+ }
+
+ String sInfo = new String(infoData);
+ Scanner scanner = new Scanner(sInfo);
+
+ JsonParser parser = new JsonParser();
+ JsonObject json = parser.parse(scanner.nextLine()).getAsJsonObject();
+
+ title[0] = json.has("title")
+ ? json.get("title").getAsString() : (json.has("fulltitle")
+ ? json.get("fulltitle").getAsString() : null);
+
+ int durationInSeconds = json.has("duration") ? json.get("duration").getAsInt() : -1;
+
+ readableDuration[0] = null;
+ if(durationInSeconds != -1)
+ {
+ readableDuration[0] = String.format(" [%d min, %d sec]",
+ TimeUnit.SECONDS.toMinutes(durationInSeconds),
+ TimeUnit.SECONDS.toSeconds(durationInSeconds) -
+ TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(durationInSeconds))
+ );
+ }
+ }).start();
+
+
+ ProcessBuilder youtube = new ProcessBuilder(
+ youtubeDlPath,
+ "-q",
+ "-f", "mp3/bestaudio/best",
+ "--no-playlist",
+ "-o", "-",
+ "--", url
+ );
+ ProcessBuilder ffmpeg = new ProcessBuilder(
+ ffmpegPath,
+ "-i", "-",
+ "-f", "mp3", //Format. mp3
+ "-movflags", "+faststart",
+ "-vbr", "4",
+ "-nostats",
+ "-preset", "ultrafast",
+ "-ac", "2", //Channels. Specify 2 for stereo audio.
+ "-ar", "48000", //Rate. Opus requires an audio rate of 48000hz
+ "-map", "a", //Makes sure to only output audio, even if the specified format supports other streams
+ "-"
+ );
+
+ Process yProcess = youtube.start();
+ Process fProcess = ffmpeg.start();
+
+ InputStream from = yProcess.getInputStream();
+ OutputStream to = fProcess.getOutputStream();
+
+ new Thread()
+ {
+ @Override
+ public void run()
+ {
+ byte[] buffer = new byte[1024];
+ int amountRead = -1;
+ try
+ {
+ while(!isInterrupted() && ((amountRead = from.read(buffer)) > -1))
+ {
+ to.write(buffer, 0, amountRead);
+ }
+ to.flush();
+
+ from.close();
+ to.close();
+
+ yProcess.destroy();
+ }
+ catch(IOException e)
+ {
+ if(!e.getMessage().contains("The pipe has been ended"))
+ {
+ logger.error("While fetching music", e);
+ }
+ }
+ }
+ }.start();
+
+ new Thread("youtube-dl ErrorStream")
+ {
+ @Override
+ public void run()
+ {
+ try
+ {
+ InputStream fromYTDL = null;
+
+ fromYTDL = yProcess.getErrorStream();
+ if(fromYTDL == null)
+ {
+ logger.error("youtube-dl ErrorStream is null");
+ }
+
+ byte[] buffer = new byte[1024];
+ int amountRead = -1;
+ while(!isInterrupted() && ((amountRead = fromYTDL.read(buffer)) > -1))
+ {
+ logger.warn("youtube-dl error: " + new String(Arrays.copyOf(buffer, amountRead)));
+ }
+ }
+ catch(IOException e)
+ {
+ logger.debug("youtube-dl", e);
+ }
+ }
+ }.start();
+
+ ExtendedTrack track = new ExtendedTrack(
+ AudioSystem.getAudioInputStream(fProcess.getInputStream()),
+ TrackSource.YOUTUBE, url, title[0], readableDuration[0]);
+
+ player.queue(track);
+
+ sendPlayMessage(track, messageBuilder);
+ }
+
+ private static void sendPlayMessage(AudioPlayer.Track track, MessageBuilder messageBuilder)
+ {
+ try
+ {
+ messageBuilder
+ .appendContent("Queued ")
+ .appendContent(track.toString(), MessageBuilder.Styles.BOLD)
+ .build();
+ }
+ catch(DiscordException |RateLimitException | MissingPermissionsException e)
+ {
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/github/langebangen/kensa/Command.java b/src/main/java/com/github/langebangen/kensa/Command.java
new file mode 100644
index 0000000..95d1202
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/Command.java
@@ -0,0 +1,44 @@
+package com.github.langebangen.kensa;
+
+/**
+ * @author langen
+ */
+public class Command
+{
+ private final Action action;
+ private final String argument;
+
+ public Command(Action action, String argument)
+ {
+ this.action = action;
+ this.argument = argument;
+ }
+
+ public Action getAction()
+ {
+ return action;
+ }
+
+ public String getArgument()
+ {
+ return argument;
+ }
+
+ public static Command parseCommand(String value)
+ {
+ if(!value.isEmpty())
+ {
+ String[] commands = value.split(" ");
+ String actionString = commands[0];
+ Action action = Action.getAction(actionString);
+ if(action != null)
+ {
+ return new Command(action, commands.length > 1
+ ? value.substring(1 + actionString.length() + value.indexOf(commands[0]))
+ : null);
+ }
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/github/langebangen/kensa/EventListener.java b/src/main/java/com/github/langebangen/kensa/EventListener.java
new file mode 100644
index 0000000..9fc1292
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/EventListener.java
@@ -0,0 +1,152 @@
+package com.github.langebangen.kensa;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sx.blah.discord.api.IDiscordClient;
+import sx.blah.discord.api.events.EventSubscriber;
+import sx.blah.discord.handle.impl.events.MessageReceivedEvent;
+import sx.blah.discord.handle.impl.events.ReadyEvent;
+import sx.blah.discord.handle.obj.IGuild;
+import sx.blah.discord.handle.obj.IMessage;
+import sx.blah.discord.handle.obj.IVoiceChannel;
+import sx.blah.discord.util.DiscordException;
+import sx.blah.discord.util.MessageBuilder;
+import sx.blah.discord.util.MissingPermissionsException;
+import sx.blah.discord.util.RateLimitException;
+import sx.blah.discord.util.audio.AudioPlayer;
+
+import java.util.List;
+
+/**
+ * @author langen
+ */
+public class EventListener
+{
+ private static final Logger logger = LoggerFactory.getLogger(EventListener.class);
+
+ private final IDiscordClient client;
+
+ public EventListener(IDiscordClient client)
+ {
+ this.client = client;
+ }
+
+ @EventSubscriber
+ public void onReady(ReadyEvent event)
+ {
+ logger.info("Logged in successfully.!");
+ }
+
+ @EventSubscriber
+ public void onMessageReceivedEvent(MessageReceivedEvent event)
+ {
+ IMessage message = event.getMessage();
+ String content = message.getContent();
+
+ Command command = Command.parseCommand(content);
+ if(command != null)
+ {
+ IGuild guild = message.getGuild();
+ MessageBuilder messageBuilder = new MessageBuilder(client).withChannel(message.getChannel());
+ AudioPlayer player = AudioPlayer.getAudioPlayerForGuild(guild);
+ switch(command.getAction())
+ {
+ case HELP:
+ sendHelpMessage(messageBuilder);
+ break;
+ case JOIN:
+ String channel = command.getArgument();
+ List voiceChannels = guild.getVoiceChannelsByName(channel);
+ if(voiceChannels.isEmpty())
+ {
+ sendMessage(messageBuilder, "No channel with name " + channel + " exists!");
+ }
+ else
+ {
+ try
+ {
+ voiceChannels.get(0).join();
+ }
+ catch(MissingPermissionsException e)
+ {
+ sendMessage(messageBuilder, "I have no permission to join this channel :frowning2:");
+ }
+ }
+ break;
+ case LEAVE:
+ client.getConnectedVoiceChannels().forEach(IVoiceChannel::leave);
+ break;
+ case PLAY:
+ String url = command.getArgument();
+ AudioStreamer.stream(url, AudioPlayer.getAudioPlayerForGuild(guild), messageBuilder);
+ break;
+ case SKIP:
+ String indexString = command.getArgument();
+ if(indexString == null)
+ {
+ //Skip current song
+ player.skip();
+ }
+ else
+ {
+ int index = Integer.parseInt(indexString);
+ player.skipTo(index);
+ }
+ break;
+ case SONG:
+ messageBuilder.appendContent("Current song: ")
+ .appendContent(player.getCurrentTrack().toString(), MessageBuilder.Styles.BOLD);
+ sendMessage(messageBuilder);
+ break;
+ case PLAYLIST:
+ List playlist = player.getPlaylist();
+ if(playlist.isEmpty())
+ {
+ sendMessage(messageBuilder, "No songs added to the playlist.");
+ }
+ else
+ {
+ int i = 1;
+ for(AudioPlayer.Track track : playlist)
+ {
+ messageBuilder.appendContent(String.format("\n %d . %s", i++, track));
+ }
+ sendMessage(messageBuilder);
+ }
+ break;
+ case CLEAR:
+ player.clear();
+ sendMessage(messageBuilder, "Playlist cleared.");
+ break;
+ }
+ }
+ }
+
+ private void sendHelpMessage(MessageBuilder messageBuilder)
+ {
+ for(Action action : Action.values())
+ {
+ messageBuilder.appendContent("\n" + action.getAction(), MessageBuilder.Styles.BOLD);
+ messageBuilder.appendContent(" - " + action.getDescription());
+ }
+ sendMessage(messageBuilder);
+ }
+
+ private void sendMessage(MessageBuilder messageBuilder, String message)
+ {
+ messageBuilder.withContent(message);
+ sendMessage(messageBuilder);
+ }
+
+ private void sendMessage(MessageBuilder messageBuilder)
+ {
+ try
+ {
+ messageBuilder.send();
+ }
+ catch(DiscordException | RateLimitException | MissingPermissionsException e)
+ {
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/github/langebangen/kensa/ExtendedTrack.java b/src/main/java/com/github/langebangen/kensa/ExtendedTrack.java
new file mode 100644
index 0000000..2c4bf25
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/ExtendedTrack.java
@@ -0,0 +1,75 @@
+package com.github.langebangen.kensa;
+
+import sx.blah.discord.handle.audio.IAudioProvider;
+import sx.blah.discord.util.audio.AudioPlayer;
+import sx.blah.discord.util.audio.providers.AudioInputStreamProvider;
+
+import javax.sound.sampled.AudioInputStream;
+import java.io.IOException;
+
+/**
+ * @author langen
+ */
+public class ExtendedTrack
+ extends AudioPlayer.Track
+{
+ private final TrackSource source;
+ private final String url;
+ private final String title;
+ private final String duration;
+
+ public ExtendedTrack(IAudioProvider provider, TrackSource source, String url, String title, String duration)
+ {
+ super(provider);
+ this.source = source;
+ this.url = url;
+ this.title = title;
+ this.duration = duration;
+ }
+
+ public ExtendedTrack(AudioInputStreamProvider provider, TrackSource source, String url, String title, String duration)
+ throws IOException
+ {
+ super(provider);
+ this.source = source;
+ this.url = url;
+ this.title = title;
+ this.duration = duration;
+ }
+
+ public ExtendedTrack(AudioInputStream stream, TrackSource source, String url, String title, String duration)
+ throws IOException
+ {
+ super(stream);
+ this.source = source;
+ this.url = url;
+ this.title = title;
+ this.duration = duration;
+ }
+
+ public TrackSource getSource()
+ {
+ return source;
+ }
+
+ public String getUrl()
+ {
+ return url;
+ }
+
+ public String getTitle()
+ {
+ return title;
+ }
+
+ public String getDuration()
+ {
+ return duration;
+ }
+
+ @Override
+ public String toString()
+ {
+ return String.format("%s %s", title != null ? title : url, duration != null ? duration : "");
+ }
+}
diff --git a/src/main/java/com/github/langebangen/kensa/KensaApp.java b/src/main/java/com/github/langebangen/kensa/KensaApp.java
new file mode 100644
index 0000000..50cd576
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/KensaApp.java
@@ -0,0 +1,47 @@
+package com.github.langebangen.kensa;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sx.blah.discord.api.ClientBuilder;
+import sx.blah.discord.api.IDiscordClient;
+import sx.blah.discord.util.DiscordException;
+import sx.blah.discord.util.RateLimitException;
+
+/**
+ * @author langen
+ */
+public class KensaApp
+{
+ private static final Logger logger = LoggerFactory.getLogger(KensaApp.class);
+
+ public static void main(String[] args)
+ throws DiscordException
+ {
+ if(args.length != 1)
+ {
+ System.out.println("The bot token must be provided in main args.");
+ System.exit(0);
+ }
+
+ final IDiscordClient dcClient = new ClientBuilder()
+ .withToken(args[0])
+ .build();
+
+ dcClient.getDispatcher().registerListener(new EventListener(dcClient));
+ dcClient.login();
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() ->
+ {
+ try
+ {
+ dcClient.logout();
+ }
+ catch (DiscordException | RateLimitException e)
+ {
+ logger.error("Error logging out.", e);
+ }
+ }));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/github/langebangen/kensa/TrackSource.java b/src/main/java/com/github/langebangen/kensa/TrackSource.java
new file mode 100644
index 0000000..1f6e4aa
--- /dev/null
+++ b/src/main/java/com/github/langebangen/kensa/TrackSource.java
@@ -0,0 +1,11 @@
+package com.github.langebangen.kensa;
+
+/**
+ * @author langen
+ */
+public enum TrackSource
+{
+ FILE,
+ URL,
+ YOUTUBE;
+}
diff --git a/src/main/resources/ffmpeg.exe b/src/main/resources/ffmpeg.exe
new file mode 100644
index 0000000..4a44141
Binary files /dev/null and b/src/main/resources/ffmpeg.exe differ
diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties
new file mode 100644
index 0000000..393e087
--- /dev/null
+++ b/src/main/resources/log4j.properties
@@ -0,0 +1,8 @@
+# Root logger option
+log4j.rootLogger=INFO, stdout
+
+# Direct log messages to stdout
+log4j.appender.stdout=org.apache.log4j.ConsoleAppender
+log4j.appender.stdout.Target=System.out
+log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
+log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
\ No newline at end of file
diff --git a/src/main/resources/youtube-dl.exe b/src/main/resources/youtube-dl.exe
new file mode 100644
index 0000000..793d9b8
Binary files /dev/null and b/src/main/resources/youtube-dl.exe differ