diff --git a/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java b/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java index 2266cab..b0544a5 100644 --- a/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java +++ b/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java @@ -1,40 +1,42 @@ package me.despawningbone.discordbot.command.music; import java.nio.ByteBuffer; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; import net.dv8tion.jda.api.audio.AudioSendHandler; /** + * Boilerplate class entirely from https://github.com/sedmelluq/lavaplayer/tree/master/demo-jda/src/main/java/com/sedmelluq/discord/lavaplayer/demo/jda + *

* This is a wrapper around AudioPlayer which makes it behave as an AudioSendHandler for JDA. As JDA calls canProvide * before every call to provide20MsAudio(), we pull the frame in canProvide() and use the frame we already pulled in * provide20MsAudio(). */ public class AudioPlayerSendHandler implements AudioSendHandler { private final AudioPlayer audioPlayer; private AudioFrame lastFrame; /** * @param audioPlayer Audio player to wrap. */ public AudioPlayerSendHandler(AudioPlayer audioPlayer) { this.audioPlayer = audioPlayer; } @Override public boolean canProvide() { lastFrame = audioPlayer.provide(); return lastFrame != null; } @Override public ByteBuffer provide20MsAudio() { return ByteBuffer.wrap(lastFrame.getData()); } @Override public boolean isOpus() { return true; } } \ No newline at end of file diff --git a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java index ee1096b..58b01df 100644 --- a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java +++ b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java @@ -1,494 +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 + 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, String user, long durMillis) { + public TrackData(String url, long durMillis, String ytAutoplayParam) { this.url = url; - this.uDis = user; + 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) { 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()) { + 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)) { + 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(!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)); + 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 { - resFuture.complete(new CommandResult(CommandResultType.NORESULT)); + 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").addParameter("search_query", search).build(); + 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(";\n")))) + 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) { 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 plName = playlist.getName(); + List tracks = playlist.getTracks(); String plId = ""; - if (!playlist.isSearchResult()) - if(url.contains("://soundcloud.com") || url.contains("://www.youtube.com")) + 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(playlist.getName(), tracks.stream().filter(t -> t != null).collect(Collectors.toList())); //only get first result if search + resultHandler.accept(plName, tracks.stream().filter(t -> t != null).collect(Collectors.toList())); } 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, 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/GuildMusicManager.java b/src/me/despawningbone/discordbot/command/music/GuildMusicManager.java index cf68db6..992f92b 100644 --- a/src/me/despawningbone/discordbot/command/music/GuildMusicManager.java +++ b/src/me/despawningbone/discordbot/command/music/GuildMusicManager.java @@ -1,115 +1,118 @@ package me.despawningbone.discordbot.command.music; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.concurrent.ScheduledFuture; import com.sedmelluq.discord.lavaplayer.filter.equalizer.EqualizerFactory; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.User; /** - * Holder for both the player and a track scheduler for one guild. + * Guild object to store music specific data and audio player / scheduler; + * Boilerplate from + * https://github.com/sedmelluq/lavaplayer/tree/master/demo-jda/src/main/java/com/sedmelluq/discord/lavaplayer/demo/jda */ public class GuildMusicManager { - /** - * Audio player for the guild. - */ - public final AudioPlayer player; - /** - * Track scheduler for the player. - */ - public final TrackScheduler scheduler; - - private HashMap> votes = new HashMap<>(); - public ScheduledFuture clearQueueCleanup = null; - public ScheduledFuture pendingCleanup = null; - public List pending = null; - private EqualizerFactory equalizer = new EqualizerFactory(); - - public int vote(User user, String type, int req) throws UnsupportedOperationException{ - List ids = votes.get(type); - if(ids == null) ids = new ArrayList<>(); - if(ids.contains(user.getId())) throw new UnsupportedOperationException("You have already voted!"); - ids.add(user.getId()); - if(ids.size() < req) { - votes.put(type, ids); //actually shouldnt need to put - return ids.size(); - } else { - votes.remove(type); - - switch(type) { //type specific cleanup stuff - case "tracks": - if(pendingCleanup != null) { - pending = null; - pendingCleanup.cancel(true); - pendingCleanup = null; - } - break; - } - - return -1; - } - - } - - public void clearVotes(String type) { - votes.remove(type); - } - - public void clearAllVotes() { - votes.clear(); - } - - public void setGain(int fromBand, Float... values) throws IllegalArgumentException { - for(int i = fromBand; i < values.length; i++) { - if(values[i] > 0.25) throw new IllegalArgumentException("A band's value is out of range."); - equalizer.setGain(i, values[i]); - } - } - - public Float[] getCurrentGain() { - Float[] vals = new Float[15]; - for(int i = 0; i < 15; i++) { - vals[i] = equalizer.getGain(i); - } - return vals; - } - - public Float[] setPresetGain(String name) throws IllegalArgumentException { - Float[] preset; - switch(name) { - case "bassboost": preset = new Float[]{ 0.08f, 0.06f, 0.05f, 0f, -0.03f, -0.05f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f }; break; - case "default": preset = new Float[]{ 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f }; break; - case "rock": preset = new Float[]{ 0.07f, 0.05f, 0.05f, -0.01f, -0.02f, -0.02f, -0.04f, -0.04f, 0.01f, 0.01f, 0.03f, 0.05f, 0.07f, 0.08f, 0.08f }; break; - default: throw new IllegalArgumentException("Unknown preset."); - } - for(int i = 0; i < preset.length; i++) { - equalizer.setGain(i, preset[i]); - } - return preset; - } - - /** - * Creates a player and a track scheduler. - * @param manager Audio player manager to use for creating the player. - */ - public GuildMusicManager(Guild guild, AudioTrackHandler handler, AudioPlayerManager manager) { - player = manager.createPlayer(); - scheduler = new TrackScheduler(guild, handler, player); - player.addListener(scheduler); - player.setFilterFactory(equalizer); - } - - /** - * @return Wrapper around AudioPlayer to use it as an AudioSendHandler. - */ - public AudioPlayerSendHandler getSendHandler() { - return new AudioPlayerSendHandler(player); - } + + public final AudioPlayer player; + + public final TrackScheduler scheduler; + + private HashMap> votes = new HashMap<>(); + public ScheduledFuture clearQueueCleanup = null; + public ScheduledFuture pendingCleanup = null; + public List pending = null; + private EqualizerFactory equalizer = new EqualizerFactory(); + + public int vote(User user, String type, int req) throws UnsupportedOperationException { + List ids = votes.get(type); + if (ids == null) ids = new ArrayList<>(); + + if (ids.contains(user.getId())) + throw new UnsupportedOperationException("You have already voted!"); + + ids.add(user.getId()); + + if (ids.size() < req) { + votes.put(type, ids); //actually shouldnt need to put + return ids.size(); + } else { //vote passed + votes.remove(type); + + switch (type) { //type specific cleanup stuff + case "tracks": + if (pendingCleanup != null) { + pending = null; + pendingCleanup.cancel(true); + pendingCleanup = null; + } + break; + } + + return -1; + } + + } + + public void clearVotes(String type) { + votes.remove(type); + } + + public void clearAllVotes() { + votes.clear(); + } + + public void setGain(int fromBand, Float... values) throws IllegalArgumentException { + for (int i = fromBand; i < values.length; i++) { + if (values[i] > 0.25) throw new IllegalArgumentException("A band's value is out of range."); + equalizer.setGain(i, values[i]); + } + } + + public Float[] getCurrentGain() { + Float[] vals = new Float[15]; + for (int i = 0; i < 15; i++) { + vals[i] = equalizer.getGain(i); + } + return vals; + } + + public Float[] setPresetGain(String name) throws IllegalArgumentException { + Float[] preset; + switch (name) { + case "bassboost": + preset = new Float[] { 0.08f, 0.06f, 0.05f, 0f, -0.03f, -0.05f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f }; + break; + case "default": + preset = new Float[] { 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f }; + break; + case "rock": + preset = new Float[] { 0.07f, 0.05f, 0.05f, -0.01f, -0.02f, -0.02f, -0.04f, -0.04f, 0.01f, 0.01f, 0.03f, 0.05f, 0.07f, 0.08f, 0.08f }; + break; + default: + throw new IllegalArgumentException("Unknown preset."); + } + for (int i = 0; i < preset.length; i++) { + equalizer.setGain(i, preset[i]); + } + return preset; + } + + //spin up a music instance for the guild + public GuildMusicManager(Guild guild, AudioTrackHandler handler, AudioPlayerManager manager) { + player = manager.createPlayer(); + scheduler = new TrackScheduler(guild, handler, player); + player.addListener(scheduler); + player.setFilterFactory(equalizer); + } + + public AudioPlayerSendHandler getSendHandler() { + return new AudioPlayerSendHandler(player); + } } \ No newline at end of file diff --git a/src/me/despawningbone/discordbot/command/music/Music.java b/src/me/despawningbone/discordbot/command/music/Music.java index 1280c51..e8d9db1 100644 --- a/src/me/despawningbone/discordbot/command/music/Music.java +++ b/src/me/despawningbone/discordbot/command/music/Music.java @@ -1,679 +1,678 @@ package me.despawningbone.discordbot.command.music; import java.awt.Color; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLConnection; import java.net.URLDecoder; import java.net.URLEncoder; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.safety.Whitelist; import org.jsoup.select.Elements; import com.google.common.base.Splitter; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.command.music.AudioTrackHandler.TrackData; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; 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; @SuppressWarnings("deprecation") public class Music extends Command { private AudioTrackHandler handler; public Music() { this.alias = Arrays.asList("m"); this.desc = "The music sub-bot!"; this.usage = ""; this.handler = new AudioTrackHandler(); //DONE merge p, pl and ps? registerSubCommand("play", Arrays.asList("p"), (c, u, m, a) -> { List params = new ArrayList<>(Arrays.asList(a)); String type = params.removeAll(Collections.singleton("-s")) ? "soundcloud" : "youtube " + (params.removeAll(Collections.singleton("-l")) ? "playlist" : "video"); if(a.length < 1) return new CommandResult(CommandResultType.INVALIDARGS, "Please enter something to search or an URL to play."); try { return handler.searchAndPlay(String.join(" ", params), type, c.getGuild().getMember(u), c).get(); } catch (NoSuchElementException e) { return new CommandResult(CommandResultType.NORESULT); } catch (InterruptedException | ExecutionException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } }, "[-s/-l] ", Arrays.asList("despacito", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "-l tpz sega", "-s asymmetry reol"), "Queue some music to play!", Arrays.asList( " * this also supports all opus streams and common audio format as long as you provide the url.", " * you can specify `-s` when searching to search soundcloud instead of youtube,", " * or `-l` to search for youtube playlists instead.")); //TODO merge? i dont think it can be though //TODO guild config for non restricted loop/autoplay registerSubCommand("autoplay", Arrays.asList("ap"), (c, u, m, a) -> { VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); if (vc.getMembers().size() <= 2 || DiscordBot.ModID.contains(u.getId())) { c.sendMessage("Autoplay mode toggled " + (handler.toggleLoopQueue(c.getGuild(), "autoplay") ? "on.\nIt will end once someone joins the music channel." : "off.")) .queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "You are currently not alone in the music channel, therefore this feature is disabled."); } }, "", null, "Auto-queues related track of the last track when you are alone!", Arrays.asList(" Note: it will autoplay indefinitely until you toggle it again or someone joins.")); registerSubCommand("loop", Arrays.asList("l", "loopqueue"), (c, u, m, a) -> { VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); if (vc.getMembers().size() <= 2 || DiscordBot.ModID.contains(u.getId())) { c.sendMessage("Looping mode toggled " + (handler.toggleLoopQueue(c.getGuild(), "loop") ? "on.\nIt will end once someone joins the music channel." : "off.")) .queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "You are currently not alone in the music channel, therefore this feature is disabled."); } }, "", null, "Loop the queue indefinitely if you are alone!", Arrays.asList(" Note: skipping tracks will remove it from the loop too.")); registerSubCommand("approve", Arrays.asList("a"), (c, u, m, a) -> { GuildMusicManager mm = handler.getGuildMusicManager(c.getGuild()); if (mm.pending == null) { return new CommandResult(CommandResultType.FAILURE, "There is currently no pending playlists/livestreams."); } Member requester = c.getGuild().getMemberByTag(mm.pending.get(0).getUserData(TrackData.class).getUserWithDiscriminator()); VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); if(vc == null) vc = requester.getVoiceState().getChannel(); //fallback to requester, usually due to bot not joined yet (first track needs approval) if(vc == null) vc = c.getGuild().getMember(u).getVoiceState().getChannel(); //fallback to approver in case requester left voice channel try { int req = (int) Math.ceil(vc.getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0); List tracks = mm.pending; //have to place before vote or else it gets wiped int votes = mm.vote(u, "tracks", req); if (votes == -1) { //automatically cleaned up in guildmusicmanager already c.sendMessage("Pending playlist/livestream approved. Queuing...").queue(); handler.queueTracks(tracks, requester); } else { c.sendMessage("You voted for the pending playlist! (" + votes + "/"+ req + ")").queue(); } return new CommandResult(CommandResultType.SUCCESS); } catch(UnsupportedOperationException e) { return new CommandResult(CommandResultType.FAILURE, e.getMessage()); } }, "", null, "Approve the pending playlist or livestream.", null); registerSubCommand("skip", Arrays.asList("s"), (c, u, m, a) -> { TrackData track = handler.getGuildMusicManager(c.getGuild()).player.getPlayingTrack().getUserData(TrackData.class); if(!u.getAsTag().equals(track.getUserWithDiscriminator())) { VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); int req = (int) Math.ceil(vc.getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0); try { int votes = track.voteSkip(u, req); if (votes != -1) { c.sendMessage("You voted to skip the current track! (" + votes + "/" + req + ")").queue(); return new CommandResult(CommandResultType.SUCCESS); } } catch (UnsupportedOperationException e) { return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } } //else skips track if no need votes or vote passed String next = handler.skipTrack(c.getGuild()); if(next != null) { c.sendMessage("Skipped to the next track: `" + next + "`.").queue(); } else { c.sendMessage("No tracks found after this one. Stopping the player...").queue(); } return new CommandResult(CommandResultType.SUCCESS); }, "", null, "Vote to skip the currently playing track.", Arrays.asList(" * this is equivalent to `forceskip` if you are the one who requested the track.")); //TODO merge? i dont think i can either though registerSubCommand("forceskip", Arrays.asList("fs"), (c, u, m, a) -> { String next = handler.skipTrack(c.getGuild()); if(next != null) { c.sendMessage("Skipped to the next track: `" + next + "`.").queue(); } else { c.sendMessage("No tracks found after this one. Stopping the player...").queue(); } return new CommandResult(CommandResultType.SUCCESS); }, "", null, "Skip the currently playing track without voting", null, EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); registerSubCommand("info", Arrays.asList("i", "trackinfo", "track", "nowplaying", "np", "current", "c"), (c, u, m, a) -> { try { c.sendMessage(handler.getTrackInfo(c.getGuild(), a.length > 0 ? Integer.parseInt(a[0]) : 1).build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } catch(NumberFormatException | NullPointerException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid track index specified."); } }, "[index]", Arrays.asList("", "2"), "See the info about a track in the queue.", Arrays.asList(" * If you did not specify an index, it will return info about the current track playing.")); registerSubCommand("queue", Arrays.asList("q", "page", "list"), (c, u, m, a) -> { try { c.sendMessage(handler.queueCheck(c.getGuild(), a.length > 0 ? Integer.parseInt(a[0]) : 1).build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } catch(NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid page index specified."); } catch(IllegalArgumentException e) { return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } }, "[page]", Arrays.asList("", "2"), "See the queue of tracks to play.", Arrays.asList(" * If you did not specify an page number, it will return the first page.")); //TODO allow pausing when alone? unpause when people join; or vote system? //TODO yet another merge lmao but this time idk coz rythm has them as seperate commands registerSubCommand("pause", Arrays.asList("pa"), (c, u, m, a) -> { try { handler.togglePause(c.getGuild(), true); c.sendMessage("Successfully paused the player.").queue(); return new CommandResult(CommandResultType.SUCCESS); } catch(IllegalStateException e) { return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } }, "", null, "Pauses the music player.", null, EnumSet.of(Permission.VOICE_MOVE_OTHERS), BotUserLevel.DEFAULT.ordinal()); registerSubCommand("resume", Arrays.asList("re", "unpause"), (c, u, m, a) -> { try { handler.togglePause(c.getGuild(), false); c.sendMessage("Successfully resumed the player.").queue(); return new CommandResult(CommandResultType.SUCCESS); } catch(IllegalStateException e) { return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } }, "", null, "Resumes the music player.", null, EnumSet.of(Permission.VOICE_MOVE_OTHERS), BotUserLevel.DEFAULT.ordinal()); registerSubCommand("setposition", Arrays.asList("set", "setpos", "seek"), (c, u, m, a) -> { try { String[] s = a[0].split("(?<=[+-])"); //get type of relative pos; only first +/- is used and consecutive +/-s get ignored String[] t = s[s.length - 1].split(":"); int index = t.length > 2 ? 1 : 0; String setT = handler.setTrackPosition(c.getGuild(), index == 1 ? Long.parseLong(t[0]) : 0 , Long.parseLong(t[index]), Long.parseLong(t[index + 1]), s.length == 1 ? "" : s[0]); c.sendMessage("Successfully set the timestamp to `" + setT + "`.").queue(); return new CommandResult(CommandResultType.SUCCESS); } catch (IndexOutOfBoundsException | NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid timestamp with colons."); } catch (IllegalArgumentException e) { return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } }, "[+/-][hr:]", Arrays.asList("4:52:21", "2:00", "+30:00", "-1:23:45"), "Set the playing position in the current track!", Arrays.asList("timestamps without signs are absolute, whereas `+`/`-` means go forward or backward from the current position for the amount of time specified respectively.", " * the person who requested the current track can always set the position, regardless of perms."), EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); //TODO merge with skip? registerSubCommand("removetrack", Arrays.asList("rt", "r", "skiptrack", "st"), (c, u, m, a) -> { if(a.length > 0) { try { int num = Integer.parseInt(a[0]); if(num == 1) return new CommandResult(CommandResultType.INVALIDARGS, "You cannot remove the current track from the queue with this command. Use !desp music skip instead."); if(num < 1) return new CommandResult(CommandResultType.INVALIDARGS, "The index you entered is invalid."); AudioTrack removed = handler.getGuildMusicManager(c.getGuild()).scheduler.removeTrack(num - 1); if(removed != null) { c.sendMessage("Removed track `" + removed.getInfo().title + "` requested by `" + removed.getUserData(TrackData.class).getUserWithDiscriminator() + "`.").queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "The track does not exist."); } } catch(NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid index number."); } } else { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter an index."); } }, "", null, "Remove a track at the specified index of the queue.", Arrays.asList(" * the person who requested the track which is to be removed can always execute the command, regardless of perms."), EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); registerSubCommand("move", Arrays.asList("m", "movetrack"), (c, u, m, a) -> { try { if(a[0].equals("1") || a[1].equals("1")) return new CommandResult(CommandResultType.INVALIDARGS, "You cannot move the currently playing track."); AudioTrack track = handler.getGuildMusicManager(c.getGuild()).scheduler.moveTrack(Integer.parseInt(a[0]) - 2, Integer.parseInt(a[1]) - 2); c.sendMessage("Successfully moved `" + track.getInfo().title + "` to position `" + a[1] + "`.").queue(); return new CommandResult(CommandResultType.SUCCESS); } catch(IndexOutOfBoundsException | NumberFormatException | NullPointerException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Please valid indices."); } }, " ", Arrays.asList("2 3"), "Move a track to the specified index.", Arrays.asList(" * you can also run this command regardless of perms if you are alone with the bot.") , EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); //TODO make vote system for this registerSubCommand("shuffle", null, (c, u, m, a) -> { VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); if (vc.getMembers().size() <= 2 || DiscordBot.ModID.contains(u.getId())) { handler.getGuildMusicManager(c.getGuild()).scheduler.shuffleQueue(); c.sendMessage("Successfully shuffled the queue.").queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "You are currently not alone in the music channel, therefore this feature is disabled."); } }, "", null, "Shuffle the tracks in the queue when you are alone!", null); //TODO make vote system for this registerSubCommand("clear", Arrays.asList("disconnect", "dc", "stop", "clearqueue"), (c, u, m, a) -> { handler.stopAndClearQueue(c.getGuild()); c.sendMessage("The queue has been cleared.").queue(); return new CommandResult(CommandResultType.SUCCESS); }, "", null, "Clear the queue and stop the player.", Arrays.asList(" * you can also run this command regardless of perms if you are alone with the bot.") , EnumSet.of(Permission.VOICE_MUTE_OTHERS, Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); registerSubCommand("lyrics", Arrays.asList("ly"), (c, u, m, a) -> { c.sendTyping().queue(); try { String search = a.length > 0 ? String.join(" ", a) : handler.getGuildMusicManager(c.getGuild()).player.getPlayingTrack().getInfo().title .split("ft.")[0].replaceAll("\\(.*?\\)", "") .replaceAll("\\[.*?\\]", "") .replaceAll("\\【.*?\\】", "") .replaceAll("-", "").trim(); getLyrics(search).forEach(em -> c.sendMessage(em).queue()); return new CommandResult(CommandResultType.SUCCESS); } catch(IllegalArgumentException | UnsupportedOperationException e) { - String erMsg = e.getMessage(); - //String rMsg = null; - if(e instanceof UnsupportedOperationException) { - String[] erSplit = erMsg.split(" \\| "); - erMsg = erSplit[0]; - //rMsg = "Lyrics too long; " + erSplit[1]; - } - return new CommandResult(CommandResultType.FAILURE, erMsg); + return new CommandResult(CommandResultType.FAILURE, e.getMessage()); } catch(NullPointerException e) { return new CommandResult(CommandResultType.INVALIDARGS, "There is nothing playing currently! Please specify a song title to search the lyrics up."); + } catch(IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } }, "[search words]", Arrays.asList(""), "Search some lyrics up!", Arrays.asList(" * if you did not specify any search words, the bot will try to fetch the lyrics of the current track playing, if any.")); //TODO equalizer persistence? //make this premium? registerSubCommand("equalizer", Arrays.asList("eq"), (c, u, m, a) -> { GuildMusicManager mm = handler.getGuildMusicManager(c.getGuild()); Float[] vals; if(a.length == 0) { vals = mm.getCurrentGain(); } else { try { try { if(a.length != 15) throw new NumberFormatException(); vals = new Float[15]; for(int i = 0; i < a.length; i++) { vals[i] = Float.parseFloat(a[i]); } mm.setGain(0, vals); } catch(NumberFormatException e) { vals = mm.setPresetGain(a[0]); } } catch(IllegalArgumentException e2) { return new CommandResult(CommandResultType.INVALIDARGS, e2.getMessage()); } } //formatting DecimalFormat df = new DecimalFormat("0.00"); df.setPositivePrefix("+"); EmbedBuilder eb = new EmbedBuilder(); eb.setTitle("Current equalizer graph"); eb.appendDescription("```\n"); for(double line = 0.25; line >= -0.25; line -= 0.05) { eb.appendDescription(df.format(line) + " "); for(int band = 0; band < 15; band++) { if(Math.abs(0.05 * Math.round(vals[band] / 0.05) - line) < 1E-7) eb.appendDescription("🔘"); else eb.appendDescription(" ❘ "); } eb.appendDescription("\n"); } eb.appendDescription("```"); eb.addField("Actual values", Arrays.asList(vals).stream().map(f -> df.format(f)).collect(Collectors.joining(" ")), false); c.sendMessage(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[bandvalues/presetname]", Arrays.asList("", "0.09 0.07 0.07 0.01 0 0 -0.02 -0.02 0.03 0.03 0.05 0.07 0.09 0.1 0.1", "bassboost"), "Sets the equalizer for the music player in this guild!", Arrays.asList("Accepts 15 bands with values ranging from -0.25 to 0.25, where -0.25 is muted and 0.25 is double volume.", "* presets include: `bassboost`, `default`, `rock`.", "Input nothing to return the current settings.", "", "Note: you might experience some audio cracking for band values >0.1, since amplifying volumes remotely does not work well.", "It is recommended to use values from -0.25 to 0.1, and turning discord volume up instead.") , EnumSet.of(Permission.VOICE_MUTE_OTHERS, Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); registerSubCommand("shutdown", null, (c, u, m, a) -> { handler.shutdown(); handler = null; c.sendMessage("Successfully destroyed the music player instance.").queue(); return new CommandResult(CommandResultType.SUCCESS); }, "", null, "Shuts down the music player sub-bot.", null, EnumSet.noneOf(Permission.class), BotUserLevel.BOT_OWNER.ordinal()); registerSubCommand("startup", Arrays.asList("start"), (c, u, m, a) -> { if(handler != null) return new CommandResult(CommandResultType.FAILURE, "An instance is already running. Please shut it down first."); else handler = new AudioTrackHandler(); c.sendMessage("Successfully created the music player instance.").queue(); return new CommandResult(CommandResultType.SUCCESS); }, "", null, "Boots up the music player sub-bot.", null, EnumSet.noneOf(Permission.class), BotUserLevel.BOT_OWNER.ordinal()); registerSubCommand("restart", Arrays.asList("reboot"), (c, u, m, a) -> { handler.shutdown(); handler = new AudioTrackHandler(); c.sendMessage("Successfully rebooted the music player instance.").queue(); return new CommandResult(CommandResultType.SUCCESS); }, "", null, "Reboots the music player sub-bot.", null, EnumSet.noneOf(Permission.class), BotUserLevel.BOT_OWNER.ordinal()); } public AudioTrackHandler getAudioTrackHandler() { return handler; } //TODO make a skipuntil command that requires voting? //prob not //TODO make clear accessible on vote? @Override public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { DiscordBot.lastMusicCmd.put(channel.getGuild().getId(), channel); Command sub = args.length > 0 ? getSubCommand(args[0].toLowerCase()) : null; if(sub != null) { //only run if not null, else hand to normal sub command handler //if handler is null it means the music player is shut down if(!sub.getName().equals("startup")) { if(handler == null) return new CommandResult(CommandResultType.FAILURE, "The music bot is in maintenance right now!"); //pre check to block out all music commands if the player is not running List exception = Arrays.asList("play", "approve", "shutdown", "lyrics", "equalizer"); GuildMusicManager mm = handler.getGuildMusicManager(channel.getGuild()); if(mm.player.getPlayingTrack() == null && !exception.contains(sub.getName())) return new CommandResult(CommandResultType.INVALIDARGS, "There are no tracks playing currently."); else { //else block out all users that are not in the same voice channel exception = Arrays.asList("lyrics", "queue", "info"); AudioManager man = channel.getGuild().getAudioManager(); if(man.isConnected() && !man.getConnectedChannel().getMembers().contains(channel.getGuild().getMember(author)) && !(exception.contains(sub.getName()) || DiscordBot.ModID.contains(author.getId()))) { return new CommandResult(CommandResultType.INVALIDARGS, "You are not in the same channel as the bot."); } } //perms overriding for queuer if(Arrays.asList("forceskip", "skip", "setposition").contains(sub.getName())) { if(author.getAsTag().equals(mm.player.getPlayingTrack().getUserData(TrackData.class).getUserWithDiscriminator())) return sub.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length)); } else if(sub.getName().equals("removetrack")) { try { if(author.getAsTag().equals(mm.scheduler.findTracks(Integer.parseInt(args[1]) - 1, 1).get(0).getUserData(TrackData.class).getUserWithDiscriminator())) return sub.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length)); } catch(Exception ignored) { ignored.printStackTrace(); } } //perms overriding for when alone if(Arrays.asList("move", "clear").contains(sub.getName())) { //should always be connected to vc if(channel.getGuild().getAudioManager().getConnectedChannel().getMembers().size() == 2) { //alone; no need to check if the other member is requester, since its checked before return sub.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length)); } } } } CommandResult res = super.execute(channel, author, msg, args); //pass to subcommand handler for perms checking if(res.getResultType() == CommandResultType.NOPERMS && Arrays.asList("forceskip", "skip", "setposition", "removetrack").contains(sub.getName())) { //add back info to command result res = new CommandResult(CommandResultType.INVALIDARGS, res.getMessage().getContentRaw().split(" to execute")[0] + ", or have requested the track to execute this command."); } return res; } + + //maybe move the lyrics stuff elsewhere? + + private final String geniusAuth = DiscordBot.tokens.getProperty("genius"); + //annotated usually isnt actual lyrics, but the following are known to be some private List whitelist = Arrays.asList("https://genius.com/Noma-jpn-brain-power-annotated"); //private ExecutorService executor = Executors.newCachedThreadPool(); //unfortunately i need to finish the api search to get the url to start the html scrape, which means i cannot use future for multithread here - public List getLyrics(String search) { //DONE dont splice words between embeds; make whole sentence to be spliced instead + public List getLyrics(String search) throws IOException { //DONE dont splice words between embeds; make whole sentence to be spliced instead //System.out.println(search); - List em = new ArrayList(); + JSONObject main = null; try { URLConnection searchCon = new URL("https://api.genius.com/search?access_token=" + geniusAuth + "&q=" + URLEncoder.encode(search, "UTF-8").replaceAll("\\+", "%20")).openConnection(); - //URLConnection searchCon = new URL("https://api.genius.com/search?q=eden%20-%20end%20credits%20%28feat.%20leah%20kelly%29").openConnection(); searchCon.addRequestProperty("User-Agent", "Mozilla/4.0"); - //searchCon.addRequestProperty("Authorization", "Bearer " + geniusAuth); InputStream searchStream = searchCon.getInputStream(); JSONTokener searchResult = new JSONTokener(searchStream); JSONArray list = new JSONObject(searchResult).getJSONObject("response").getJSONArray("hits"); - JSONObject main = null; + + //get first valid result for(int i = 0; i < list.length(); i++) { JSONObject check = list.getJSONObject(i).getJSONObject("result"); if(!check.getString("url").endsWith("lyrics") && !whitelist.contains(check.getString("url"))) { System.out.println(" " + check.getString("url")); continue; } main = check; break; } - if(main == null) { - throw new IllegalArgumentException("There were no results unfortunately :cry:"); - } + searchStream.close(); - String url = main.getString("url"); - Element href; - try { - Document document = Jsoup.connect(url).userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:75.0) Gecko/20100101 Firefox/75.0").get(); - href = document.body(); - } catch (IOException e1) { - e1.printStackTrace(); - throw new IllegalArgumentException("Something went wrong."); - } - Element el = href.selectFirst("div[class=\"lyrics\"]"); - String s; - if(el == null) { - el = href.selectFirst("div[class*=\"Lyrics__Root\"]"); //second version of the page - el.select("div[class*=\"Ad__Container\"]").remove(); - el.select("a, span, i, div[class*=\"Lyrics__Container\"]").unwrap(); - s = el.html().replaceAll("\\\\n", "\n"); - } else { - el.select("br").append("\\n"); - el.select("p").prepend("\\n\\n"); - s = el.html().replaceAll("\\\\n", "\n"); - } - String lyrics = Jsoup.clean(s, "", Whitelist.none(), new Document.OutputSettings().prettyPrint(false)); - List lyList = new ArrayList(Splitter.fixedLength(2000).splitToList( - lyrics.trim().replaceAll("&", "&").replaceAll(" ", " ") - .replaceAll("\n ", "\n"))); //retain this formatting? it looks more clean yet more jumbled at the same time - if(lyList.size() > 5) { - throw new UnsupportedOperationException("The lyrics is too long for a normal song xD | URL: " + url); - } - EmbedBuilder eb = new EmbedBuilder(); - String auImg = main.getJSONObject("primary_artist").getString("header_image_url"); - eb.setAuthor(main.getString("full_title"), url, auImg.contains("https://assets.genius.com/images/default_avatar_300.png") ? null : auImg); - String alImg = main.getString("song_art_image_thumbnail_url"); - eb.setThumbnail(alImg.contains("https://assets.genius.com/images/default_cover_image.png") ? null : alImg); - formatLyrics(eb, "**Lyrics**\n\n " + (lyList.size() > 0 ? lyList.get(0).trim() : "N/A"), lyList, 0); - eb.setColor(new Color(255, 255, 100)); - if(lyList.size() > 1) { - em.add(eb.build()); - for(int i = 1; i < lyList.size(); i++) { - EmbedBuilder loopEm = new EmbedBuilder(); - loopEm.setColor(new Color(255, 255, 100)); - formatLyrics(loopEm, lyList.get(i), lyList, i); - if(i == lyList.size() - 1) { - setFinalLyricsEmbed(loopEm, main, href); - } - em.add(loopEm.build()); - } - } else { - setFinalLyricsEmbed(eb, main, href); - em = Arrays.asList(eb.build()); - } - //System.out.println(lyrics); - } catch (JSONException | IOException | ArrayIndexOutOfBoundsException e) { - if(e instanceof JSONException || e instanceof ArrayIndexOutOfBoundsException) { - e.printStackTrace(); - throw new IllegalArgumentException("There were no results unfortunately :cry:"); - } else { - e.printStackTrace(); - throw new IllegalArgumentException("Something went wrong."); - } + } catch (JSONException | ArrayIndexOutOfBoundsException e) { + e.printStackTrace(); + throw new IllegalArgumentException("There were no results unfortunately :cry:"); + } + + if(main == null) { + throw new IllegalArgumentException("There were no results unfortunately :cry:"); } + + //get actual lyrics + String url = main.getString("url"); + Document document = Jsoup.connect(url).userAgent("Mozilla/4.0").get(); + Element html = document.body(); + + //parse and lint + Element el = html.selectFirst("div[class=\"lyrics\"]"); + String s; + if(el == null) { + el = html.selectFirst("div[class*=\"Lyrics__Root\"]"); //second version of the page + el.select("div[class*=\"Ad__Container\"]").remove(); + el.select("a, span, i, div[class*=\"Lyrics__Container\"]").unwrap(); + s = el.html().replaceAll("\\\\n", "\n"); + } else { + el.select("br").append("\\n"); + el.select("p").prepend("\\n\\n"); + s = el.html().replaceAll("\\\\n", "\n"); + } + String lyrics = Jsoup.clean(s, "", Whitelist.none(), new Document.OutputSettings().prettyPrint(false)); + + //split into lyrics segments + List lyList = new ArrayList(Splitter.fixedLength(2000).splitToList( + lyrics.trim().replaceAll("&", "&").replaceAll(" ", " ") + .replaceAll("\n ", "\n"))); //retain this formatting? it looks more clean yet more jumbled at the same time + + if(lyList.size() > 5) { //failover catch for whats most likely not lyrics + throw new UnsupportedOperationException("The lyrics is too long for a normal song :poop:"); + } + + //build first embed + EmbedBuilder eb = new EmbedBuilder(); + String auImg = main.getJSONObject("primary_artist").getString("header_image_url"); + eb.setAuthor(main.getString("full_title"), url, auImg.contains("https://assets.genius.com/images/default_avatar_300.png") ? null : auImg); + String alImg = main.getString("song_art_image_thumbnail_url"); + eb.setThumbnail(alImg.contains("https://assets.genius.com/images/default_cover_image.png") ? null : alImg); + formatLyrics(eb, "**Lyrics**\n\n " + (lyList.size() > 0 ? lyList.get(0).trim() : "N/A"), lyList, 0); + eb.setColor(new Color(255, 255, 100)); + + //build rest of the embeds + List em = new ArrayList(); + if(lyList.size() > 1) { + em.add(eb.build()); + + for(int i = 1; i < lyList.size(); i++) { + EmbedBuilder loopEm = new EmbedBuilder(); + loopEm.setColor(new Color(255, 255, 100)); + formatLyrics(loopEm, lyList.get(i), lyList, i); + if(i == lyList.size() - 1) { + setFinalLyricsEmbed(loopEm, main, html); + } + em.add(loopEm.build()); + } + } else { //first and last same embed, thus set final + setFinalLyricsEmbed(eb, main, html); + em = Arrays.asList(eb.build()); + } + + //System.out.println(lyrics); return em; } + //set given embed's description, shifting lyrics to the next embed "page" if overflow private void formatLyrics(EmbedBuilder eb, String segment, List lyList, int i) { if(segment.length() < 2000) { eb.setDescription(segment.trim()); return; } String includeSeg = segment.length() > 2000 ? segment.substring(0, 2000) : segment; int index = includeSeg.lastIndexOf("\n"); - //System.out.println(segment); - //System.out.println(segment.substring(0, index)); eb.setDescription(segment.substring(0, index)); String move = segment.substring(includeSeg.lastIndexOf("\n")); try { if(!move.isEmpty()) lyList.set(i + 1, move + lyList.get(i + 1)); } catch (IndexOutOfBoundsException e) { lyList.add(move); } } - private void setFinalLyricsEmbed(EmbedBuilder eb, JSONObject main, Element href) { //change the format for the supplementary info in artists and albums from italic to sth else? - //System.out.println("test"); - + //appends metadata portion to the given embed (should be the last one) + private void setFinalLyricsEmbed(EmbedBuilder eb, JSONObject main, Element html) { //change the format for the supplementary info in artists and albums from italic to sth else? try { String name = main.getJSONObject("primary_artist").getString("name"); - //name = "[" + name + "](https://genius.com/artists/" + URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") + ")"; - /*StringBuilder sb = new StringBuilder(); - - data.getJSONArray("artists").forEach(obj -> { - String n = obj.toString(); - if(!n.equals(name)) { - sb.append(obj.toString() + " "); - } - }); - String others = sb.toString();*/ + try { - String others = href.select("script:containsData(_sf_async_config)").html().split("_sf_async_config.authors = '")[1].split("';")[0].replace(",", ", "); + String others = html.select("script:containsData(_sf_async_config)").html().split("_sf_async_config.authors = '")[1].split("';")[0].replace(",", ", "); eb.addField("Artists", new StringBuffer(others).insert(others.indexOf(name) + name.length() + 2, "\n*").toString() + "*", true); } catch (IndexOutOfBoundsException e) { eb.addField("Artist", name, true); } - //eb.addField("Artist" + (others.isEmpty() ? "" : "s"), name + (others.isEmpty() ? "" : ",\n*" + others.trim().replaceAll(" ", ", ") + "*"), true); //add link to it? } catch (JSONException e) { e.printStackTrace(); eb.addField("Artist", "Unknown", true); - }/* catch (UnsupportedEncodingException e1) { - e1.printStackTrace(); - }*/ + } - if(!href.select("div[class=\"lyrics\"]").isEmpty()) { - Elements buffer = href.select("span:contains(Album) ~ span[class=\"metadata_unit-info\"] a"); + if(!html.select("div[class=\"lyrics\"]").isEmpty()) { + Elements buffer = html.select("span:contains(Album) ~ span[class=\"metadata_unit-info\"] a"); + + //parse albums if(buffer.size() > 0) { String album = buffer.get(0).ownText(); StringBuilder sb = new StringBuilder(); - JSONObject data = new JSONObject(href.select("script[type=\"application/ld+json\"]").get(0).html()); + JSONObject data = new JSONObject(html.select("script[type=\"application/ld+json\"]").get(0).html()); data.getJSONArray("inAlbum").forEach(obj -> { String n = ((JSONObject) obj).getString("name"); if(!n.equals(album)) { sb.append("*" + n.trim() + "*\n"); } }); String others = sb.toString(); eb.addField("Album" + (others.isEmpty() ? "" : "s"), album + (others.isEmpty() ? "" : "\n" + others), true); //add link? } - buffer = href.select("span:contains(Release Date) ~ span[class*=\"metadata_unit-info\"]"); + //parse release date + buffer = html.select("span:contains(Release Date) ~ span[class*=\"metadata_unit-info\"]"); if(buffer.size() > 0) eb.addField("Release Date", buffer.get(0).ownText(), true); + //parse tags try { - String sbuff = URLDecoder.decode(href.select("img[src*=\"page-genres=\"]").first().absUrl("src").split("page-genres=")[1].split("&page-type")[0].replaceAll("\\+", " ").split("&")[0], "UTF-8"); + String sbuff = URLDecoder.decode(html.select("img[src*=\"page-genres=\"]").first().absUrl("src").split("page-genres=")[1].split("&page-type")[0].replaceAll("\\+", " ").split("&")[0], "UTF-8"); if(!sbuff.isEmpty()) { eb.addField("Tags", sbuff.trim().replaceAll(",", ", "), false); } } catch (UnsupportedEncodingException | NullPointerException e) { e.printStackTrace(); } - buffer = href.select("div[class=\"annotation_label\"] ~ div[class=\"rich_text_formatting\"]"); + //parse background info + buffer = html.select("div[class=\"annotation_label\"] ~ div[class=\"rich_text_formatting\"]"); if(buffer.size() > 0) { String bgInfo = buffer.get(0).text(); if(!bgInfo.trim().isEmpty()) { if(bgInfo.length() > 1024) { bgInfo = bgInfo.substring(0, 1021) + "..."; } eb.addField("Background info", bgInfo, false); } } } else { //second version -- actually has a lot more info, use? System.out.println("Second ver"); - String temp = href.select("script:containsData(__PRELOADED_STATE__)").html().split("JSON.parse\\(\'", 2)[1].split("\'\\);\n", 2)[0]; + String temp = html.select("script:containsData(__PRELOADED_STATE__)").html().split("JSON.parse\\(\'", 2)[1].split("\'\\);\n", 2)[0]; JSONObject preload = new JSONObject(StringEscapeUtils.unescapeJson(temp)); JSONObject song = preload.getJSONObject("entities").getJSONObject("songs").getJSONObject(Integer.toString(preload.getJSONObject("songPage").getInt("song"))); - StringBuilder sb = new StringBuilder(); + //parse albums try { + StringBuilder sb = new StringBuilder(); String album = IntStream.range(0, song.getJSONArray("trackingData").length()).mapToObj(i -> song.getJSONArray("trackingData").getJSONObject(i)).filter(obj -> obj.getString("key").equals("Primary Album")).findFirst().get().getString("value").trim(); song.getJSONArray("albums").forEach(obj -> { String n = ((JSONObject) obj).getString("name"); if(!n.trim().equals(album)) { sb.append("*" + n.trim() + "*\n"); } }); String others = sb.toString(); eb.addField("Album" + (others.isEmpty() ? "" : "s"), album + (others.isEmpty() ? "" : "\n" + others), true); //add link? } catch (JSONException ignored) { //no albums; value is null } + //release date eb.addField("Release Date", song.getString("releaseDateForDisplay"), true); + //parse tags ArrayList tags = new ArrayList<>(); for(int i = 0; i < song.getJSONArray("tags").length(); i++) { song.getJSONArray("tags").getJSONObject(i).getString("name"); } - if(!tags.isEmpty()) eb.addField("Background info", String.join(", ", tags), false); + if(!tags.isEmpty()) eb.addField("Tags", String.join(", ", tags), false); + //parse background info String bgInfo = song.getJSONObject("description").getString("markdown"); if(bgInfo.length() > 1024) { bgInfo = bgInfo.substring(0, 1021) + "..."; } if(!bgInfo.equals("?")) eb.addField("Background info", bgInfo, false); //apparently its ? for empty descs lol } eb.setFooter((main.getJSONObject("stats").has("pageviews") ? NumberFormat.getIntegerInstance().format(main.getJSONObject("stats").getInt("pageviews")) + " views | " : "") + "Powered by Genius", "https://yt3.ggpht.com/a/AATXAJzPOKLs0x9W_yNpTUPvwg-zeSnJaxqzf2CU0g=s900-c-k-c0xffffffff-no-rj-mo"); } } diff --git a/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java index 8fac900..386a80f 100644 --- a/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java +++ b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java @@ -1,322 +1,317 @@ 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) { 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) { 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) { 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) { 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()); } } diff --git a/src/me/despawningbone/discordbot/command/music/TrackScheduler.java b/src/me/despawningbone/discordbot/command/music/TrackScheduler.java index defb8cf..6c63351 100644 --- a/src/me/despawningbone/discordbot/command/music/TrackScheduler.java +++ b/src/me/despawningbone/discordbot/command/music/TrackScheduler.java @@ -1,240 +1,192 @@ 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.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; +import org.jsoup.Connection; +import org.jsoup.Jsoup; /** - * This class schedules tracks for the audio player. It contains the queue of - * tracks. + * Handles everything related to music queuing + * Boilerplate from https://github.com/sedmelluq/lavaplayer/tree/master/demo-jda/src/main/java/com/sedmelluq/discord/lavaplayer/demo/jda */ 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; + loop = null; //reset loop and autoplay tracking 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 + } //externally called from AudioTrackHandler.skipTrack() - 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 + public void queueAutoplay(AudioTrack track) { //DONE check duplicate please, some can get into a dead loop like cosMo@暴走P - WalpurgisNacht and Ice - 絶 //using yt tracking params now; still can get into loops but random should fix ap.ex.submit(() -> { //async so it can free up the event - try (HttpInterface httpInterface = ap.httpInterfaceManager.getInterface()) { - //System.out.println("autoplay"); + try { + //System.out.println("autoplay"); //debug - URI orig = new URIBuilder("https://www.youtube.com/watch").addParameter("v", track.getIdentifier()).build(); + Connection con = Jsoup.connect("https://www.youtube.com/watch?v=" + track.getIdentifier() + "&pbj=1"); + + String cmd = track.getUserData(TrackData.class).getYoutubeAutoplayParam(); + if(cmd != null) con.data("command", cmd); //use tracking params to prevent dupes - 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"); + JSONArray autoplayFeed = new JSONArray(new JSONTokener(con.ignoreContentType(true).post().text())) + .getJSONObject(3).getJSONObject("response").getJSONObject("contents").getJSONObject("twoColumnWatchNextResults") + .getJSONObject("secondaryResults").getJSONObject("secondaryResults").getJSONArray("results"); + + //filter non video results out + for(int i = 0; i < autoplayFeed.length(); i++) + if(!autoplayFeed.getJSONObject(i).has("compactVideoRenderer")) + autoplayFeed.remove(i--); //remove and reset index pointer by 1 + + //choose one from top 2 results (should always have more than 2); second result should still be consistent due to tracking unlike without + JSONObject autoplay = autoplayFeed.getJSONObject(ThreadLocalRandom.current().nextInt(2)) + .getJSONObject("compactVideoRenderer").getJSONObject("navigationEndpoint"); + + + String url = "https://youtube.com" + autoplay.getJSONObject("commandMetadata").getJSONObject("webCommandMetadata").getString("url"); - //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; - }); - } + //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, auto.getDuration(), autoplay.toString())); //set new tracking + 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; + }); } 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 + clone.setUserData(new TrackData(clone.getUserData(TrackData.class))); //clone track data 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