diff --git a/pom.xml b/pom.xml index 688081f..bfff50d 100644 --- a/pom.xml +++ b/pom.xml @@ -1,165 +1,165 @@ 4.0.0 Bot Bot 1.0-SNAPSHOT despbot A discord bot 1.8 1.8 src maven-compiler-plugin 3.5.1 1.8 1.8 org.awaitility awaitility 3.0.0 org.slf4j slf4j-api 1.8.0-alpha2 org.slf4j slf4j-log4j12 1.8.0-alpha2 log4j apache-log4j-extras 1.2.17 commons-logging commons-logging 1.2 com.sedmelluq lavaplayer - 1.3.50 + 1.3.55 com.sedmelluq jda-nas 1.1.0 se.michaelthelin.spotify spotify-web-api-java 6.0.0 commons-io commons-io 2.5 com.vdurmont emoji-java 3.3.0 net.java.dev.jna jna 4.5.0 org.apache.commons commons-lang3 3.6 commons-io commons-io 2.5 org.apache.httpcomponents httpcore 4.4.6 org.json json 20170516 net.objecthunter exp4j 0.4.8 net.dv8tion JDA 4.1.1_155 org.seleniumhq.selenium selenium-java 3.11.0 org.reflections reflections 0.9.11 org.apache.commons commons-math3 3.6.1 org.knowm.xchart xchart 3.6.4 org.xerial sqlite-jdbc 3.25.2 com.zaxxer HikariCP 3.3.1 central bintray https://jcenter.bintray.com diff --git a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java index 3d2e926..54df05a 100644 --- a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java +++ b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java @@ -1,463 +1,493 @@ package me.despawningbone.discordbot.command.music; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; -import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Collectors; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.utils.URIBuilder; import org.awaitility.Awaitility; import org.awaitility.core.ConditionTimeoutException; -import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager; import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.wrapper.spotify.SpotifyApi; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.MessageBuilder; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.VoiceChannel; import net.dv8tion.jda.api.managers.AudioManager; import net.dv8tion.jda.internal.utils.PermissionUtil; public class AudioTrackHandler { private ConcurrentHashMap musicManagers; private AudioPlayerManager playerManager; - - final String GAPI = DiscordBot.tokens.getProperty("google"); //package final + + final HttpInterfaceManager httpInterfaceManager; //package final since TrackScheduler will also access it public ScheduledExecutorService ex = Executors.newScheduledThreadPool(2, new ThreadFactoryBuilder().setDaemon(true).setNameFormat("audio-scheduler-%d").build()); static class TrackData { private String url; private String uDis; private String fDur; private List votes = new ArrayList<>(); public String getUrl() { return url; } public String getUserWithDiscriminator() { return uDis; } public String getFormattedDuration() { return fDur; } public int voteSkip(User user, int req) { if (votes.contains(user.getId())) throw new UnsupportedOperationException("You have already voted!"); votes.add(user.getId()); if (votes.size() < req) { return votes.size(); } else { votes.clear(); return -1; } } public TrackData(String url, User user, long durMillis) { this.url = url; this.uDis = user.getName() + "#" + user.getDiscriminator(); this.fDur = MiscUtils.convertMillis(durMillis); } //overload for autoplay public TrackData(String url, String user, long durMillis) { this.url = url; this.uDis = user; this.fDur = MiscUtils.convertMillis(durMillis); } } public AudioTrackHandler() { this.musicManagers = new ConcurrentHashMap<>(); this.playerManager = new DefaultAudioPlayerManager(); + this.httpInterfaceManager = HttpClientTools.createCookielessThreadLocalManager(); playerManager.setFrameBufferDuration(1000); playerManager.getConfiguration().setFilterHotSwapEnabled(true); try { //register spotify source manager playerManager.registerSourceManager(new SpotifyAudioSourceManager(new SpotifyApi.Builder().setClientId(DiscordBot.tokens.getProperty("spotifyid")).setClientSecret(DiscordBot.tokens.getProperty("spotifysecret")).build(), playerManager, this)); } catch (Exception e) { e.printStackTrace(); } AudioSourceManagers.registerRemoteSources(playerManager); AudioSourceManagers.registerLocalSource(playerManager); } public GuildMusicManager getGuildMusicManager(Guild guild) { String guildId = guild.getId(); GuildMusicManager musicManager; musicManager = musicManagers.get(guildId); if (musicManager == null) { musicManager = new GuildMusicManager(guild, this, playerManager); musicManagers.put(guildId, musicManager); } guild.getAudioManager().setSendingHandler(musicManager.getSendHandler()); return musicManager; } public CompletableFuture searchAndPlay(String search, String type, Member user, TextChannel channel) throws NoSuchElementException { CompletableFuture resFuture = new CompletableFuture<>(); String url = search; try { new URL(url); } catch(MalformedURLException e) { - url = fetchUrlFromSearch(search, type); + try { + url = fetchUrlFromSearch(search, type); + } catch (Exception ex) { + resFuture.complete(new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex))); + return resFuture; //something broke, halt further codes + } } if (url == null) throw new NoSuchElementException(); load(user, url, (n, l) -> { if (l.size() > 100) { resFuture.complete(new CommandResult(CommandResultType.INVALIDARGS, "Cannot queue in a playlist of more than 100 tracks.")); return; } if(!user.getVoiceState().inVoiceChannel()) { resFuture.complete(new CommandResult(CommandResultType.FAILURE, "You are not in a voice channel.")); return; } if((channel.getGuild().getAudioManager().isConnected() && !channel.getGuild().getAudioManager().getConnectedChannel().getId().equals(user.getVoiceState().getChannel().getId()))) { resFuture.complete(new CommandResult(CommandResultType.FAILURE, "You are not in the same channel as the bot.")); return; } if (l.size() > 10 || l.stream().anyMatch(t -> t.getInfo().isStream)) { GuildMusicManager mm = getGuildMusicManager(user.getGuild()); int req = (int) Math.ceil(user.getVoiceState().getChannel().getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0); if(req > 1 && !DiscordBot.ModID.contains(user.getId())) { if(mm.pending != null) { resFuture.complete(new CommandResult(CommandResultType.FAILURE, "There is already a pending playlist or livestream.")); return; } mm.vote(user.getUser(), "tracks", req); //self vote; should never return -1 (success) coz req > 1 channel.sendMessage("Due to the total duration of your requested tracks, it has been added to pending. It will be automatically removed if it has not been approved by the users in the channel for longer than 1 minute.\n" + "Others in the channel should use `!desp music approve` to vote.").queue(); mm.pending = l; mm.pendingCleanup = ex.schedule(() -> { mm.clearVotes("tracks"); mm.pending = null; mm.pendingCleanup = null; channel.sendMessage(user.getUser().getName() + "'s" + (l.size() > 1 ? " playlist " : " livestream ") + "request has timed out.").queue(); }, 1, TimeUnit.MINUTES); resFuture.complete(new CommandResult(CommandResultType.SUCCESS, "Pending approval")); return; } } try { if(!l.isEmpty()) { int startIndex = queueTracks(l, user) + 1; if (l.size() == 1){ channel.sendMessage("Adding `" + l.get(0).getInfo().title + "` (" + (l.get(0).getDuration() == Long.MAX_VALUE ? "N/A" : l.get(0).getUserData(TrackData.class).getFormattedDuration()) + ") to the queue. [`" + startIndex + "`]").queue(); } else if (l.size() > 1) { channel.sendMessage("Adding playlist `" + n + "` to the queue, queue now has a total of `" + (startIndex + l.size() - 1) + "` tracks.").queue(); channel.sendMessage((startIndex == 1 ? "Playing `" : "First track: `") + l.get(0).getInfo().title + "` (" + l.get(0).getUserData(TrackData.class).getFormattedDuration() + ") [`" + startIndex + "`].").queue(); } resFuture.complete(new CommandResult(CommandResultType.SUCCESS)); } else { resFuture.complete(new CommandResult(CommandResultType.NORESULT)); } } catch (UnsupportedOperationException e) { resFuture.complete(new CommandResult(CommandResultType.FAILURE, e.getMessage())); } }, (ex) -> resFuture.complete(ex.getStackTrace()[0].getMethodName().equals("readPlaylistName") ? new CommandResult(CommandResultType.FAILURE, "Cannot read the playlist specified. Is it private?") : new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)))); return resFuture; } - private String fetchUrlFromSearch(String search, String type) { + private String fetchUrlFromSearch(String search, String type) throws IOException { if (type.startsWith("youtube")) { type = type.substring(8, type.length()); if (type.equals("video")) { return "ytsearch:" + search; - } else { //only use yt api when playlist (and autoplay) due to strict rate limit - try { //NOADD check if theres a way to search for playlists and vids at the same time //using different commands now - InputStream input = new URL("https://www.googleapis.com/youtube/v3/search?part=snippet%20&q=" + URLEncoder.encode(search, "UTF-8") + "%20&type=" + type + "%20&key=" + GAPI).openStream(); - JSONObject result = new JSONObject(new JSONTokener(new InputStreamReader(input, "UTF-8"))).getJSONArray("items").getJSONObject(0); - JSONObject id = result.getJSONObject("id"); - String vidID = id.getString(type + "Id"); //can switch to pure playlist impl tbh i aint even using this api for normal vids - return "https://www.youtube.com/" + (type.equals("video") ? "watch?v=" : "playlist?list=") + vidID; - } catch (IOException e) { - e.printStackTrace(); - return null; - } catch (JSONException e) { - return null; + } else { //scrape with our own code since YoutubeSearchProvider only scrapes videos + //NOADD check if theres a way to search for playlists and vids at the same time //using different commands now + try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) { + URI url = new URIBuilder("https://www.youtube.com/results").addParameter("search_query", search).build(); + + try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(url))) { //connection code borrowed from lavaplayer's YoutubeSearchProvider + int statusCode = response.getStatusLine().getStatusCode(); + if (!HttpClientTools.isSuccessWithContent(statusCode)) { + throw new IOException("Invalid status code for search response: " + statusCode); + } + + //start parsing + String data = IOUtils.toString(response.getEntity().getContent(), "UTF-8"); + data = data.substring(data.indexOf("var ytInitialData = ") + 20); + JSONObject result = new JSONObject(new JSONTokener(data.substring(0, data.indexOf(";\n")))) + .getJSONObject("contents").getJSONObject("twoColumnSearchResultsRenderer").getJSONObject("primaryContents") + .getJSONObject("sectionListRenderer").getJSONArray("contents").getJSONObject(0).getJSONObject("itemSectionRenderer"); + + //iterate to get first playlist + for(Object obj : result.getJSONArray("contents")) { + JSONObject renderer = (JSONObject) obj; + if(renderer.has("playlistRenderer")) { + return "https://www.youtube.com" + renderer.getJSONObject("playlistRenderer").getJSONObject("navigationEndpoint") + .getJSONObject("commandMetadata").getJSONObject("webCommandMetadata").getString("url"); //return first found + } + } + + return null; //if no result + } + } catch (URISyntaxException e) { + e.printStackTrace(); //should be unreachable } } } else if (type.equals("soundcloud")) { return "scsearch:" + search; } throw new UnsupportedOperationException("This provider is not implemented yet!"); } //package private void load(Member user, String url, BiConsumer> resultHandler, Consumer exceptionally) { playerManager.loadItemOrdered(getGuildMusicManager(user.getGuild()), url, new AudioLoadResultHandler() { @Override public void trackLoaded(AudioTrack track) { try { track.setUserData(new TrackData(track.getInfo().uri, user.getUser(), track.getDuration())); resultHandler.accept(null, Arrays.asList(track)); } catch(Exception e) { exceptionally.accept(e); //so i dont lose my sanity over silenced errors } } @Override public void playlistLoaded(AudioPlaylist playlist) { try { if(playlist.getTracks().size() == 0) { //somehow its possible; do the same as noResult() if(url.startsWith("ytsearch:")) load(user, url.replaceFirst("yt", "sc"), resultHandler, exceptionally); //searches in soundcloud, since apparently yt pretty frequently returns no result else resultHandler.accept(null, new ArrayList<>()); return; } List tracks = playlist.isSearchResult() ? playlist.getTracks().subList(0, 1) : playlist.getTracks(); String plId = ""; if (!playlist.isSearchResult()) if(url.contains("://soundcloud.com") || url.contains("://www.youtube.com")) plId = url.contains("://soundcloud.com") ? "?in=" + url.split("soundcloud.com/")[1] : "&list=" + url.split("list=")[1].split("&")[0]; for (AudioTrack track : tracks) //TODO tell users that we skipped some tracks? if(track != null) track.setUserData(new TrackData(track.getInfo().uri + plId, user.getUser(), track.getDuration())); if (playlist.getSelectedTrack() != null) tracks.add(0, tracks.remove(tracks.indexOf(playlist.getSelectedTrack()))); //shift selected track to first track resultHandler.accept(playlist.getName(), tracks.stream().filter(t -> t != null).collect(Collectors.toList())); //only get first result if search } catch(Exception e) { exceptionally.accept(e); //so i dont lose my sanity over silenced errors } } @Override public void noMatches() { if(url.startsWith("ytsearch:")) load(user, url.replaceFirst("yt", "sc"), resultHandler, exceptionally); //searches in soundcloud, since apparently yt pretty frequently returns no result else resultHandler.accept(null, new ArrayList<>()); } @Override public void loadFailed(FriendlyException exception) { if(exception.getMessage().contains("Unknown file format.") && url.contains("open.spotify.com")) { resultHandler.accept(null, new ArrayList<>()); //TODO TEMPORARY FIX } else { exceptionally.accept(exception.getCause() != null ? exception.getCause() : exception); } } }); } public int queueTracks(List tracks, Member user) throws UnsupportedOperationException { Guild guild = user.getGuild(); GuildMusicManager musicManager = getGuildMusicManager(guild); int startIndex = musicManager.scheduler.getQueueSize() + (musicManager.player.getPlayingTrack() != null ? 1 : 0); if (user.getVoiceState().inVoiceChannel()) { if (!guild.getAudioManager().isConnected() && !guild.getAudioManager().isAttemptingToConnect()) { VoiceChannel voice = user.getVoiceState().getChannel(); if (PermissionUtil.checkPermission(voice, guild.getSelfMember(), Permission.VOICE_CONNECT, Permission.VOICE_SPEAK)) { guild.getAudioManager().openAudioConnection(voice); //already checked permissions so no need to try catch } else { throw new UnsupportedOperationException("The bot cannot play music in that channel."); } } } else { throw new UnsupportedOperationException("You are currently not in a voice channel."); } try { if (!guild.getAudioManager().isConnected()) { Awaitility.await().atMost(3, TimeUnit.SECONDS).until(() -> guild.getAudioManager().isConnected()); } } catch (ConditionTimeoutException e) { throw new UnsupportedOperationException("Error while connecting to voice channel: The connection timed out."); } if (guild.getAudioManager().getConnectedChannel().equals(user.getVoiceState().getChannel())) { for (AudioTrack track : tracks) musicManager.scheduler.queue(track); //nulls should already be handled; if it aint its my fault lmao } else { throw new UnsupportedOperationException("You are currently not in the same channel as the bot."); } return startIndex; //if it successfully returned it means that nothing failed } public String skipTrack(Guild guild) { GuildMusicManager musicManager = getGuildMusicManager(guild); musicManager.scheduler.nextTrack(); musicManager.player.setPaused(false); //implicit resume try { return musicManager.player.getPlayingTrack().getInfo().title; } catch (NullPointerException e) { musicManager.scheduler.loop = null; return null; } } public String setTrackPosition(Guild guild, long hour, long min, long sec) throws IllegalArgumentException { GuildMusicManager mm = getGuildMusicManager(guild); Long millis = TimeUnit.HOURS.toMillis(hour) + TimeUnit.MINUTES.toMillis(min) + TimeUnit.SECONDS.toMillis(sec); AudioTrack track = mm.player.getPlayingTrack(); if (track.getDuration() > millis && !track.getInfo().isStream) { track.setPosition(millis); return MiscUtils.convertMillis(track.getPosition()); } else if (track.getInfo().isStream) { throw new IllegalArgumentException("You cannot set the track time in a stream!"); } else { throw new IllegalArgumentException("You cannot set the track time over the track duration."); } } //not zero-based public MessageBuilder getTrackInfo(Guild guild, int index) { //TODO add views, etc by storing them when getting with lavaplayer? GuildMusicManager mm = getGuildMusicManager(guild); if(index - 1 > mm.scheduler.getQueueSize() || index < 1) return null; AudioTrack track = index == 1 ? mm.player.getPlayingTrack() : mm.scheduler.findTracks(index - 1, 1).get(0); TrackData data = track.getUserData(TrackData.class); MessageBuilder smsg = new MessageBuilder(); String fpos = MiscUtils.convertMillis(track.getPosition()); String fdur = data.getFormattedDuration(); smsg.append((index == 1 ? "Current" : MiscUtils.ordinal(index)) + " track: `" + track.getInfo().title + "` (" + fpos + "/" + (track.getDuration() == Long.MAX_VALUE ? "???" : fdur) + ")\n"); smsg.append("Author: " + track.getInfo().author + "\n"); smsg.append("Requested by: `" + data.getUserWithDiscriminator() + "`\n"); String timeTag = ""; if(data.getUrl().startsWith("https://www.youtube.com")) timeTag = "&="; else if(data.getUrl().startsWith("https://soundcloud.com")) timeTag = "#="; smsg.append("URL: " + data.getUrl() + (timeTag.isEmpty() ? "" : timeTag + TimeUnit.MILLISECONDS.toSeconds(track.getPosition()))); return smsg; } //TODO migrate to embed? public MessageBuilder queueCheck(Guild guild, int page) throws IllegalArgumentException { GuildMusicManager mm = getGuildMusicManager(guild); AudioTrack playing = mm.player.getPlayingTrack(); if(playing == null) return null; MessageBuilder smsg = new MessageBuilder(); List tracks = mm.scheduler.findTracks(1, Integer.MAX_VALUE).stream().filter(a -> a != null).collect(Collectors.toList()); //get all tracks in queue tracks.add(0, playing); int maxPage = (int) Math.ceil(tracks.size() / 10f); if(page > maxPage) throw new IllegalArgumentException("There is no such page."); smsg.append("The current queue (page " + page + "/" + maxPage + "): \n"); if (mm.scheduler.loop != null) { smsg.append("There is a total of `" + tracks.size() + "` tracks " + (mm.scheduler.loop.equals("loop") ? "looping" : "in autoplay") + ".\n\n"); } else { long millis = 0; for(AudioTrack track : tracks) millis += track.getDuration(); smsg.append("There is a total of `" + tracks.size() + "` tracks queued" + ((millis == Long.MAX_VALUE || millis < 0) ? ".\n\n" : ", with a total duration of `" + MiscUtils.convertMillis(millis - playing.getPosition()) + "`.\n\n")); } int times = (page - 1) * 10; for (AudioTrack track : tracks.subList((page - 1) * 10, Math.min(tracks.size(), page * 10))) { times++; TrackData data = track.getUserData(TrackData.class); smsg.append("[" + times + "]: `" + track.getInfo().title + "` (" + (track.getDuration() == Long.MAX_VALUE ? "N/A" : data.getFormattedDuration()) + ") requested by `" + data.getUserWithDiscriminator() + "`\n"); } if(maxPage > page) smsg.append("\nDo `!desp music queue " + (page + 1) + "` to see the next page."); return smsg; } public boolean togglePause(Guild guild, boolean pause) throws IllegalStateException { GuildMusicManager mm = getGuildMusicManager(guild); if (mm.player.isPaused() == !pause) { mm.player.setPaused(pause); return mm.player.isPaused(); } else { throw new IllegalStateException("The player is already " + (mm.player.isPaused() ? "paused!" : "unpaused!")); } } public boolean toggleLoopQueue(Guild guild, String type) { type = type.toLowerCase(); GuildMusicManager mm = getGuildMusicManager(guild); if (mm.scheduler.loop == null || !mm.scheduler.loop.equals(type)) { mm.scheduler.loop = type; if (type != null && type.equals("autoplay") && mm.scheduler.getQueueSize() < 1) { mm.scheduler.queueAutoplay(mm.player.getPlayingTrack()); } return true; } else { //remove autoplay queued track when disabling autoplay? if (mm.scheduler.loop.equals("autoplay") && mm.scheduler.getQueueSize() == 1 && mm.scheduler.findTracks(1, 1).get(0).getUserData(TrackData.class).uDis.equals("Autoplay")) { //including autoplay, theres only 2 tracks; only remove tracks that is autoplayed mm.scheduler.removeTrack(1); } mm.scheduler.loop = null; return false; } } public void stopAndClearQueue(Guild guild) { GuildMusicManager mm = getGuildMusicManager(guild); mm.pending = null; mm.pendingCleanup = null; mm.clearQueueCleanup = null; mm.scheduler.loop = null; mm.scheduler.clearSchedulerQueue(); mm.clearAllVotes(); mm.player.stopTrack(); mm.player.setPaused(false); guild.getAudioManager().closeAudioConnection(); } public void shutdown() { musicManagers.forEach((s, mm) -> { //System.out.println(DiscordBot.mainJDA.getGuildById(s).getName()); mm.player.destroy(); mm.scheduler.clearSchedulerQueue(); AudioManager man = DiscordBot.mainJDA.getGuildById(s).getAudioManager(); if(man.isConnected() || man.isAttemptingToConnect()) { man.closeAudioConnection(); DiscordBot.lastMusicCmd.get(s).sendMessage("The music bot is going into maintenance and it will now disconnect. Sorry for the inconvenience.").queue(); } }); musicManagers = null; //so further operations wont be possible even if i forgot to set this instance to null playerManager.shutdown(); ex.shutdown(); } } diff --git a/src/me/despawningbone/discordbot/command/music/TrackScheduler.java b/src/me/despawningbone/discordbot/command/music/TrackScheduler.java index d29f201..defb8cf 100644 --- a/src/me/despawningbone/discordbot/command/music/TrackScheduler.java +++ b/src/me/despawningbone/discordbot/command/music/TrackScheduler.java @@ -1,200 +1,240 @@ package me.despawningbone.discordbot.command.music; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.music.AudioTrackHandler.TrackData; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.URL; +import java.io.IOException; +import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +import org.apache.commons.io.IOUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.utils.URIBuilder; +import org.json.JSONArray; import org.json.JSONObject; import org.json.JSONTokener; /** * This class schedules tracks for the audio player. It contains the queue of * tracks. */ public class TrackScheduler extends AudioEventAdapter { private final AudioPlayer player; private final BlockingQueue queue; private AudioTrackHandler ap; private Guild guild; public String loop = null; //way too lazy to use getter setters lmao /** * @param player * The audio player this scheduler uses */ public TrackScheduler(Guild parent, AudioTrackHandler handler, AudioPlayer player) { this.player = player; this.queue = new LinkedBlockingQueue<>(); this.ap = handler; this.guild = parent; } /** * Add the next track to queue or play right away if nothing is in the * queue. * * @param track * The track to play or add to queue. */ public void queue(AudioTrack track) { // Calling startTrack with the noInterrupt set to true will start the track only if nothing is currently playing. If // something is playing, it returns false and does nothing. In that case the player was already playing so this // track goes to the queue instead. if (!player.startTrack(track, true)) { queue.offer(track); } else if (loop != null && loop.equals("autoplay")) { //i dont think this is needed as people need to play something before autoplay can be toggled anyways queueAutoplay(track); } } /** * Start the next track, stopping the current one if it is playing. */ public void nextTrack() { //DONE rewrite to not include q.remove here so that stuff like interrupted wont break the queue? // Start the next track, regardless of if something is already playing or not. In case queue was empty, we are // giving null to startTrack, which is a valid argument and will simply stop the player. AudioTrack track = queue.poll(); player.startTrack(track, false); if(track == null) { //System.out.println("finished"); //debug loop = null; delayCloseConnection(player); //required because if not it will throw InterruptedException } } //seems to be called internally somehow; even when mayStartNext is false (REPLACED, STOPPED etc) this still fires //NVM ITS CALLED FROM AudioTrackHandler.skipTrack() LOL public void queueAutoplay(AudioTrack track) { //check duplicate please, some can get into a dead loop like cosMo@暴走P - WalpurgisNacht and Ice - 絶 //well randoming it works ap.ex.submit(() -> { //async so it can free up the event - try { + try (HttpInterface httpInterface = ap.httpInterfaceManager.getInterface()) { //System.out.println("autoplay"); - InputStream input = new URL("https://www.googleapis.com/youtube/v3/search?part=snippet&relatedToVideoId=" + track.getIdentifier() + "&type=video&key=" + ap.GAPI).openStream(); - JSONTokener result = new JSONTokener(new InputStreamReader(input, "UTF-8")); - Member temp; - try { - temp = guild.getMemberByTag(track.getUserData(TrackData.class).getUserWithDiscriminator()); - } catch (IllegalArgumentException e) { //track was autoplay queued before this - temp = guild.getMemberById(DiscordBot.BotID); //fallback + + URI orig = new URIBuilder("https://www.youtube.com/watch").addParameter("v", track.getIdentifier()).build(); + + try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(orig))) { //connection code borrowed from lavaplayer's YoutubeSearchProvider + int statusCode = response.getStatusLine().getStatusCode(); + if (!HttpClientTools.isSuccessWithContent(statusCode)) { + throw new IOException("Invalid status code for search response: " + statusCode); + } + + //start parsing + String data = IOUtils.toString(response.getEntity().getContent(), "UTF-8"); + data = data.substring(data.indexOf("window[\"ytInitialData\"] = ") + 26); + + for (int i = 0; i < 5 && !data.startsWith("{"); i++) { //reconnect in case it doesnt fetch correctly; give up after 5 tries (should be more than enough) + response.close(); + try(CloseableHttpResponse retry = httpInterface.execute(new HttpGet(orig))) { + data = IOUtils.toString(retry.getEntity().getContent(), "UTF-8"); + data = data.substring(data.indexOf("window[\"ytInitialData\"] = ") + 26); + } + } + + JSONArray arr = new JSONObject(new JSONTokener(data.substring(0, data.indexOf(";\n")))) + .getJSONObject("contents").getJSONObject("twoColumnWatchNextResults").getJSONObject("secondaryResults") + .getJSONObject("secondaryResults").getJSONArray("results"); + + //DONE reimplement random selection between top 2 results to alleviate infinite loops + JSONObject result = ThreadLocalRandom.current().nextBoolean() ? + arr.getJSONObject(0).getJSONObject("compactAutoplayRenderer").getJSONArray("contents").getJSONObject(0).getJSONObject("compactVideoRenderer") //get autoplay video renderer + : null; + if(result == null) + for(Object obj : arr) { //else find first video renderer + JSONObject renderer = (JSONObject) obj; + if(renderer.has("compactVideoRenderer")) { + result = renderer.getJSONObject("compactVideoRenderer"); + } + } + + //JSONObject result = new JSONObject(new JSONTokener(data.substring(0, data.indexOf(";\n")))).getJSONObject("webWatchNextResponseExtensionData") + // .getJSONObject("contents").getJSONObject("twoColumnWatchNextResults").getJSONObject("secondaryResults").getJSONObject("secondaryResults") + // .getJSONObject("results").getJSONObject("compactAutoplayRenderer").getJSONArray("contents").getJSONObject(0).getJSONObject("compactVideoRenderer"); + String url = "https://youtube.com/watch?v=" + result.getString("videoId"); + + //load + Member user = guild.getMemberById(DiscordBot.BotID); //no need to use original track user as it would introduce performance overhead and other complications when leaving channels + ap.load(user, url, (n, l) -> { + AudioTrack auto = l.get(0); + auto.setUserData(new TrackData(auto.getInfo().uri, "Autoplay", auto.getDuration())); //change name to autoplay + ap.queueTracks(l, user); //no need sublist coz l is always a single video + }, ex -> { + ex.printStackTrace(); + DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("Something went wrong when loading next autoplay track: " + ex.getMessage()).queue(); + loop = null; + }); } - final Member user = temp; - int t = ThreadLocalRandom.current().nextInt(2); - String url = "https://youtube.com/watch?v=" + new JSONObject(result).getJSONArray("items").getJSONObject(t).getJSONObject("id").getString("videoId"); - ap.load(user, url, (n, l) -> { - AudioTrack auto = l.get(0); - auto.setUserData(new TrackData(auto.getInfo().uri, "Autoplay", auto.getDuration())); - ap.queueTracks(l.subList(0, 1), user); - }, ex -> { - ex.printStackTrace(); - DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("Something went wrong when loading next autoplay track: " + ex.getMessage()).queue(); - loop = null; - }); } catch (Exception e) { e.printStackTrace(); DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("Something went wrong when loading next autoplay track. Is the last track a youtube video?").queue(); loop = null; } }); } public List findTracks(int startIndex, int range) { AudioTrack[] a = queue.toArray(new AudioTrack[queue.size()]); int i = startIndex - 1 + range < 0 ? Integer.MAX_VALUE : startIndex - 1 + range; //prevent overflow AudioTrack[] t = Arrays.copyOfRange(a, startIndex - 1, Math.min(queue.size() + 1, i)); //accounts for first track player thats not in queue return Arrays.asList(t); } public AudioTrack removeTrack(int num) { Iterator i = queue.iterator(); num = num - 1; for (int times = 0; i.hasNext(); times++) { AudioTrack removed = i.next(); if (num == times) { i.remove(); return removed; } } return null; } public AudioTrack moveTrack(int from, int to) { List q = new ArrayList<>(Arrays.asList(queue.toArray(new AudioTrack[queue.size()]))); AudioTrack track = q.remove(from); q.add(to, track); synchronized (queue) { //obtain lock and operate before releasing or else queue might not be complete queue.clear(); for(AudioTrack t : q) queue.offer(t); } return track; } public void shuffleQueue() { List q = Arrays.asList(queue.toArray(new AudioTrack[queue.size()])); Collections.shuffle(q); synchronized (queue) { //obtain lock and operate before releasing or else queue might not be complete queue.clear(); for(AudioTrack t : q) queue.offer(t); } } public void clearSchedulerQueue() { queue.clear(); } public int getQueueSize() { return queue.size(); } @Override public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) { // Only start the next track if the end reason is suitable for it (FINISHED or LOAD_FAILED or REPLACED) //System.out.println(endReason); //debug boolean mayStartNext = endReason.mayStartNext; if (mayStartNext) { //doesnt queue clone if skipped if (loop != null && loop.equals("loop")) { //so what the hecc if loop is null and i do loop.equals("Loop") it freezes the thread AudioTrack clone = track.makeClone(); TrackData origData = clone.getUserData(TrackData.class); clone.setUserData(new TrackData(origData.getUrl(), origData.getUserWithDiscriminator(), clone.getDuration())); //wipe votes queue.offer(clone); } nextTrack(); } if(mayStartNext || endReason == AudioTrackEndReason.REPLACED) { //queues new if skipped too if (loop != null && loop.equals("autoplay") && queue.size() < 1) { queueAutoplay(player.getPlayingTrack()); } } } private void delayCloseConnection(AudioPlayer player) { //TODO configurable delay; dont leave if new things play within the delay ap.ex.schedule(() -> guild.getAudioManager().closeAudioConnection(), 1, TimeUnit.MILLISECONDS); } } \ No newline at end of file