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