diff --git a/pom.xml b/pom.xml index bfff50d..dcde20b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,165 +1,171 @@ 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 + + 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.55 - + + + + log4j + apache-log4j-extras + 1.2.17 + + + + commons-logging + commons-logging + 1.2 + + + com.sedmelluq + lavaplayer + 1.3.77 + 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 - - - + + + 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 - + + + + org.xerial + sqlite-jdbc + 3.25.2 + + + + com.zaxxer + HikariCP + 3.3.1 + + + + org.jsoup + jsoup + 1.13.1 + - - central - bintray - https://jcenter.bintray.com - + + dv8tion + m2-dv8tion + https://m2.dv8tion.net/releases + diff --git a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java index 58b01df..9058eeb 100644 --- a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java +++ b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java @@ -1,522 +1,522 @@ package me.despawningbone.discordbot.command.music; import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; 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.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; /** * Helper class for the entire music subbot instance; * For working in tandem with Music.java */ public class AudioTrackHandler { private ConcurrentHashMap musicManagers; private AudioPlayerManager playerManager; final HttpInterfaceManager httpInterfaceManager; //package final since TrackScheduler will also access it //not anymore, but its fine leaving it as is 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 String ytAp = null; private List votes = new ArrayList<>(); public String getUrl() { return url; } public String getUserWithDiscriminator() { return uDis; } public String getFormattedDuration() { return fDur; } public String getYoutubeAutoplayParam() { return ytAp; } 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); //ytAp is default null } //overload for autoplay public TrackData(String url, long durMillis, String ytAutoplayParam) { this.url = url; this.uDis = "Autoplay"; this.fDur = MiscUtils.convertMillis(durMillis); this.ytAp = ytAutoplayParam; //null for other autoplays } //for cloning; reset votes public TrackData(TrackData orig) { this.url = orig.url; this.uDis = orig.uDis; this.fDur = orig.fDur; this.ytAp = orig.ytAp; //null for other autoplays } } 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) { + } catch (MalformedURLException e) { 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.isEmpty()) { resFuture.complete(new CommandResult(CommandResultType.NORESULT)); return; } 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()) { //have to check here for pending to always work even though queueTrack would check again 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)) { //put to pending 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 everything passes try queuing int startIndex = queueTracks(l, user) + 1; if (n == null) { //no name == not playlist 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 { 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)); } 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) throws IOException { if (type.startsWith("youtube")) { type = type.substring(8, type.length()); if (type.equals("video")) { return "ytsearch:" + search; } 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") //sp is filter param, EgIQAw== is the base64 encoding of playlist option .addParameter("search_query", search).addParameter("sp", "EgIQAw==").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(";")))) .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) { + } 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; } String plName = playlist.getName(); List tracks = playlist.getTracks(); String plId = ""; if(playlist.isSearchResult()) { tracks = tracks.subList(0, 1); //only get first result if search plName = null; //no actual playlist name } else { if(url.contains("://soundcloud.com") || url.contains("://www.youtube.com")) //add pl id if is playlist 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(plName, tracks.stream().filter(t -> t != null).collect(Collectors.toList())); - } catch(Exception e) { + } 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")) { + if((exception.getMessage() != null && 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, String rel) 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(!rel.isEmpty()) millis = track.getPosition() + (rel.equals("-") ? -millis : millis); 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(); } //should ALWAYS be called before discarding this instance 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/SpotifyAudioSourceManager.java b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java index 386a80f..0f9d59c 100644 --- a/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java +++ b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java @@ -1,317 +1,316 @@ package me.despawningbone.discordbot.command.music; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.apache.hc.core5.http.ParseException; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; import org.awaitility.Awaitility; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioItem; import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; import com.sedmelluq.discord.lavaplayer.track.AudioReference; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; import com.sedmelluq.discord.lavaplayer.track.BasicAudioPlaylist; import com.wrapper.spotify.SpotifyApi; import com.wrapper.spotify.enums.ModelObjectType; import com.wrapper.spotify.exceptions.SpotifyWebApiException; import com.wrapper.spotify.exceptions.detailed.NotFoundException; import com.wrapper.spotify.model_objects.credentials.ClientCredentials; import com.wrapper.spotify.model_objects.specification.Album; import com.wrapper.spotify.model_objects.specification.Paging; import com.wrapper.spotify.model_objects.specification.Playlist; import com.wrapper.spotify.model_objects.specification.PlaylistTrack; import com.wrapper.spotify.model_objects.specification.Track; import com.wrapper.spotify.model_objects.specification.TrackSimplified; import com.wrapper.spotify.requests.data.albums.GetAlbumsTracksRequest; import com.wrapper.spotify.requests.data.playlists.GetPlaylistsItemsRequest; /** * Code mostly modified from https://github.com/lijamez/tonbot-plugin-music/ * @author lijamez @ github */ public class SpotifyAudioSourceManager implements AudioSourceManager { private static final String SPOTIFY_DOMAIN = "open.spotify.com"; private static final int EXPECTED_PATH_COMPONENTS = 2; private SpotifyApi spotifyApi; private YoutubeAudioSourceManager manager; public SpotifyAudioSourceManager(SpotifyApi spotifyApi, AudioPlayerManager parent, AudioTrackHandler handler) throws Exception { this.spotifyApi = spotifyApi; handler.ex.submit(() -> { Awaitility.await().until(() -> parent.source(YoutubeAudioSourceManager.class) != null); //needed to ensure its loaded this.manager = parent.source(YoutubeAudioSourceManager.class); }); refreshSpotifyApi(handler); } private void refreshSpotifyApi(AudioTrackHandler handler) throws Exception { ClientCredentials cred = spotifyApi.clientCredentials().build().execute(); spotifyApi.setAccessToken(cred.getAccessToken()); handler.ex.schedule(() -> { try { refreshSpotifyApi(handler); } catch (Exception e) { e.printStackTrace(); //DONE disable the source manager? spotifyApi = null; } }, cred.getExpiresIn(), TimeUnit.SECONDS); } @Override public String getSourceName() { return "Spotify Playlist"; } @Override - public AudioItem loadItem(DefaultAudioPlayerManager manager, AudioReference reference) { + public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference) { if(spotifyApi == null) return null; //disabled due to broken api try { URL url = new URL(reference.identifier); if (!StringUtils.equals(url.getHost(), SPOTIFY_DOMAIN)) { return null; } AudioItem audioItem = null; audioItem = handleAsPlaylist(url, manager); if (audioItem == null) { audioItem = handleAsTrack(url, manager); } return audioItem; } catch (MalformedURLException e) { return null; } } - private AudioTrack handleAsTrack(URL url, DefaultAudioPlayerManager man) { + private AudioTrack handleAsTrack(URL url, AudioPlayerManager man) { Path path = Paths.get(url.getPath()); if (path.getNameCount() < 2) { return null; } if (!StringUtils.equals(path.getName(0).toString(), "track")) { return null; } String trackId = path.getName(1).toString(); TrackSimplified track; try { Track t = spotifyApi.getTrack(trackId).build().execute(); track = new TrackSimplified.Builder().setArtists(t.getArtists()).setName(t.getName()).setDurationMs(t.getDurationMs()).build(); } catch (IOException | SpotifyWebApiException | ParseException e) { throw new IllegalStateException("Unable to fetch track from Spotify API.", e); } return getAudioTracks(Arrays.asList(track), man).get(0); } - private BasicAudioPlaylist handleAsPlaylist(URL url, DefaultAudioPlayerManager man) { + private BasicAudioPlaylist handleAsPlaylist(URL url, AudioPlayerManager man) { String playlistKey; try { playlistKey = extractPlaylistId(url); } catch (IllegalArgumentException e) { return null; } String name; List tracks; try { Playlist playlist = spotifyApi.getPlaylist(playlistKey) .build().execute(); name = playlist.getName(); tracks = getAllPlaylistTracks(playlist).stream().map(pt -> pt.getTrack()) .filter(pt -> pt.getType() == ModelObjectType.TRACK).map(pt -> { Track t = (Track) pt; //it doesnt have any methods to translate Track to TrackSimplified, so i had to do this; it only uses 3 params anyways return new TrackSimplified.Builder().setArtists(t.getArtists()).setName(t.getName()).setDurationMs(t.getDurationMs()).build(); }).collect(Collectors.toList()); } catch (IOException | SpotifyWebApiException | ParseException e) { if(e instanceof NotFoundException) { //try searching as album try { Album album = spotifyApi.getAlbum(playlistKey).build().execute(); name = album.getName(); tracks = getAllAlbumTracks(album); } catch (ParseException | SpotifyWebApiException | IOException e1) { throw new IllegalStateException("Unable to fetch playlist from Spotify API.", e1); } } else { throw new IllegalStateException("Unable to fetch playlist from Spotify API.", e); } } List audioTracks = getAudioTracks(tracks, man); return new BasicAudioPlaylist(name, audioTracks, null, false); } private List getAllPlaylistTracks(Playlist playlist) { List playlistTracks = new ArrayList<>(); Paging currentPage = playlist.getTracks(); do { playlistTracks.addAll(Arrays.asList(currentPage.getItems())); if (currentPage.getNext() == null) { currentPage = null; } else { try { URI nextPageUri = new URI(currentPage.getNext()); List queryPairs = URLEncodedUtils.parse(nextPageUri, StandardCharsets.UTF_8); GetPlaylistsItemsRequest.Builder b = spotifyApi.getPlaylistsItems(playlist.getId()); for (NameValuePair queryPair : queryPairs) { b = b.setBodyParameter(queryPair.getName(), queryPair.getValue()); } currentPage = b.build().execute(); } catch (IOException | SpotifyWebApiException | ParseException e) { throw new IllegalStateException("Unable to query Spotify for playlist tracks.", e); } catch (URISyntaxException e) { throw new IllegalStateException("Spotify returned an invalid 'next page' URI.", e); } } } while (currentPage != null); return playlistTracks; } private List getAllAlbumTracks(Album album) { List albumTracks = new ArrayList<>(); Paging currentPage = album.getTracks(); do { albumTracks.addAll(Arrays.asList(currentPage.getItems())); if (currentPage.getNext() == null) { currentPage = null; } else { try { URI nextPageUri = new URI(currentPage.getNext()); List queryPairs = URLEncodedUtils.parse(nextPageUri, StandardCharsets.UTF_8); GetAlbumsTracksRequest.Builder b = spotifyApi.getAlbumsTracks(album.getId()); for (NameValuePair queryPair : queryPairs) { b = b.setBodyParameter(queryPair.getName(), queryPair.getValue()); } currentPage = b.build().execute(); } catch (IOException | SpotifyWebApiException | ParseException e) { throw new IllegalStateException("Unable to query Spotify for album tracks.", e); } catch (URISyntaxException e) { throw new IllegalStateException("Spotify returned an invalid 'next page' URI.", e); } } } while (currentPage != null); return albumTracks; } private String extractPlaylistId(URL url) { Path path = Paths.get(url.getPath()); if (path.getNameCount() < EXPECTED_PATH_COMPONENTS) { throw new IllegalArgumentException("Not enough path components."); } if (!Arrays.asList("playlist", "album").contains(path.getName(0).toString())) { throw new IllegalArgumentException("URL doesn't appear to be a playlist."); } String playlistId = path.getName(1).toString(); if (StringUtils.isBlank(playlistId)) { throw new IllegalArgumentException("Playlist ID is blank."); } return playlistId; } @Override public boolean isTrackEncodable(AudioTrack track) { return false; } @Override public void encodeTrack(AudioTrack track, DataOutput output) throws IOException { throw new UnsupportedOperationException("encodeTrack is unsupported."); } @Override public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) throws IOException { throw new UnsupportedOperationException("decodeTrack is unsupported."); } @Override public void shutdown() { } - private List getAudioTracks(List tracks, DefaultAudioPlayerManager manager) { + private List getAudioTracks(List tracks, AudioPlayerManager manager) { return tracks.parallelStream().map(track -> { //parallelStream made a world of difference in loading times lmao String artist = track.getArtists().length < 1 ? "" : track.getArtists()[0].getName(); AudioItem item = this.manager.loadItem(manager, new AudioReference("ytsearch:" + artist + " " + track.getName(), null)); if (item instanceof AudioPlaylist) { AudioPlaylist audioPlaylist = (AudioPlaylist) item; // The number of matches is limited to reduce the chances of matching against // less than optimal results. // The best match is the one that has the smallest track duration delta. YoutubeAudioTrack bestMatch = audioPlaylist.getTracks().stream().limit(3) .map(t -> (YoutubeAudioTrack) t).min((o1, o2) -> { long o1TimeDelta = Math.abs(o1.getDuration() - track.getDurationMs()); long o2TimeDelta = Math.abs(o2.getDuration() - track.getDurationMs()); return (int) (o1TimeDelta - o2TimeDelta); }).orElse(null); return bestMatch; } else if (item instanceof YoutubeAudioTrack) { return (YoutubeAudioTrack) item; } else if (item instanceof AudioReference) { //no results; retry once more System.out.println("Spotify Source Manager: Retry needed for " + track.getName()); item = this.manager.loadItem(manager, new AudioReference("ytsearch:" + artist + " " + track.getName(), null)); if(item instanceof AudioPlaylist) item = ((AudioPlaylist) item).getTracks().get(0); //cba doing the best match lmao else if(!(item instanceof YoutubeAudioTrack)) return null; //if not playlist and track return null again return (YoutubeAudioTrack) item; } else { throw new IllegalArgumentException("Unknown AudioItem"); //should never throw } }).collect(Collectors.toList()); } }