diff --git a/.classpath b/.classpath
index 149cb3c..5798e46 100644
--- a/.classpath
+++ b/.classpath
@@ -1,20 +1,21 @@
+
diff --git a/pom.xml b/pom.xml
index ec97a9f..e102537 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,184 +1,235 @@
4.0.0
Bot
Bot
1.0-SNAPSHOT
despbot
A discord bot
1.8
1.8
src
org.apache.maven.plugins
maven-shade-plugin
3.2.1
log4j:**
org/apache/log4j/**
commons-logging:commons-logging
org/apache/commons/logging/**
org.slf4j:**
**
**:sqlite-jdbc**
**
me.despawningbone.discordbot.DiscordBot
true
package
shade
+
+ org.apache.maven.plugins
+ maven-scm-plugin
+ 2.1.0
+
+
+
+ connection
+ scm:git:https://github.com/lavalink-devs/youtube-source.git
+ target/lavalink-yt-src
+ 1.11.1
+ tag
+
+ process-sources
+
+ checkout
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+ 1.5
+
+
+ add-source
+ generate-sources
+
+ add-source
+
+
+
+
+
+
+
+
+
+
+
+ com.grack
+ nanojson
+ 1.7
+
+
+ org.mozilla
+ rhino-engine
+ 1.7.15
+
+
org.awaitility
awaitility
3.0.0
org.slf4j
slf4j-log4j12
1.8.0-alpha2
log4j
apache-log4j-extras
1.2.17
commons-logging
commons-logging
1.2
com.github.devoxin
lavaplayer
1.9.1
se.michaelthelin.spotify
spotify-web-api-java
6.0.0
commons-io
commons-io
2.5
org.apache.commons
commons-lang3
3.6
org.apache.httpcomponents
httpcore
4.4.6
org.json
json
20170516
net.objecthunter
exp4j
0.4.8
com.github.discord-jda
JDA
v5.2.1
org.reflections
reflections
0.9.11
org.apache.commons
commons-math3
3.6.1
org.knowm.xchart
xchart
3.6.4
org.xerial
sqlite-jdbc
3.25.2
com.zaxxer
HikariCP
3.3.1
org.jsoup
jsoup
1.13.1
jitpack.io
https://jitpack.io
diff --git a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java
index 29d9c4d..6abde85 100644
--- a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java
+++ b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java
@@ -1,522 +1,526 @@
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 dev.lavalink.youtube.YoutubeAudioSourceManager;
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.utils.messages.MessageCreateBuilder;
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.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.managers.AudioManager;
import net.dv8tion.jda.internal.utils.PermissionUtil;
/**
* Helper class for the entire music subbot instance;
* For working in tandem with Music.java
*/
public class AudioTrackHandler {
private ConcurrentHashMap musicManagers;
private AudioPlayerManager playerManager;
final HttpInterfaceManager httpInterfaceManager; //package final since TrackScheduler will also access it //not anymore, but its fine leaving it as is
public ScheduledExecutorService ex = Executors.newScheduledThreadPool(2, new ThreadFactoryBuilder().setDaemon(true).setNameFormat("audio-scheduler-%d").build());
static class TrackData {
private String url;
private String uDis;
private String fDur;
private String ytAp = null;
private List votes = new ArrayList<>();
public String getUrl() {
return url;
}
public String getUserWithDiscriminator() {
return uDis;
}
public String getFormattedDuration() {
return fDur;
}
public String getYoutubeAutoplayParam() {
return ytAp;
}
public int voteSkip(User user, int req) {
if (votes.contains(user.getId()))
throw new UnsupportedOperationException("You have already voted!");
votes.add(user.getId());
if (votes.size() < req) {
return votes.size();
} else {
votes.clear();
return -1;
}
}
public TrackData(String url, User user, long durMillis) {
this.url = url;
this.uDis = user.getName() + "#" + user.getDiscriminator();
this.fDur = MiscUtils.convertMillis(durMillis);
//ytAp is default null
}
//overload for autoplay
public TrackData(String url, long durMillis, String ytAutoplayParam) {
this.url = url;
this.uDis = "Autoplay";
this.fDur = MiscUtils.convertMillis(durMillis);
this.ytAp = ytAutoplayParam; //null for other autoplays
}
//for cloning; reset votes
public TrackData(TrackData orig) {
this.url = orig.url;
this.uDis = orig.uDis;
this.fDur = orig.fDur;
this.ytAp = orig.ytAp; //null for other autoplays
}
}
public AudioTrackHandler() {
this.musicManagers = new ConcurrentHashMap<>();
this.playerManager = new DefaultAudioPlayerManager();
this.httpInterfaceManager = HttpClientTools.createCookielessThreadLocalManager();
playerManager.setFrameBufferDuration(1000);
playerManager.getConfiguration().setFilterHotSwapEnabled(true);
- try { //register spotify source manager
+ 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));
+ //register new youtube source manager to replace the internal/broken one
+ playerManager.registerSourceManager(new YoutubeAudioSourceManager());
} 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().inAudioChannel()) { //have to check here for pending to always work even though queueTrack would check again
resFuture.complete(new CommandResult(CommandResultType.FAILURE, "You are not in a voice channel."));
return;
}
if((channel.getGuild().getAudioManager().isConnected() && !channel.getGuild().getAudioManager().getConnectedChannel().getId().equals(user.getVoiceState().getChannel().getId()))) {
resFuture.complete(new CommandResult(CommandResultType.FAILURE, "You are not in the same channel as the bot."));
return;
}
if (l.size() > 10 || l.stream().anyMatch(t -> t.getInfo().isStream)) { //put to pending
GuildMusicManager mm = getGuildMusicManager(user.getGuild());
int req = (int) Math.ceil(user.getVoiceState().getChannel().getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0);
if(req > 1 && !DiscordBot.ModID.contains(user.getId())) {
if(mm.pending != null) {
resFuture.complete(new CommandResult(CommandResultType.FAILURE, "There is already a pending playlist or livestream."));
return;
}
mm.vote(user.getUser(), "tracks", req); //self vote; should never return -1 (success) coz req > 1
channel.sendMessage("Due to the total duration of your requested tracks, it has been added to pending. It will be automatically removed if it has not been approved by the users in the channel for longer than 1 minute.\n" + "Others in the channel should use `!desp music approve` to vote.").queue();
mm.pending = l;
mm.pendingCleanup = ex.schedule(() -> {
mm.clearVotes("tracks");
mm.pending = null;
mm.pendingCleanup = null;
channel.sendMessage(user.getUser().getName() + "'s" + (l.size() > 1 ? " playlist " : " livestream ") + "request has timed out.").queue();
}, 1, TimeUnit.MINUTES);
resFuture.complete(new CommandResult(CommandResultType.SUCCESS, "Pending approval"));
return;
}
}
try { //if everything passes try queuing
int startIndex = queueTracks(l, user) + 1;
if (n == null) { //no name == not playlist
channel.sendMessage("Adding `" + l.get(0).getInfo().title + "` (" + (l.get(0).getDuration() == Long.MAX_VALUE ? "N/A" : l.get(0).getUserData(TrackData.class).getFormattedDuration()) + ") to the queue. [`" + startIndex + "`]").queue();
} else {
channel.sendMessage("Adding playlist `" + n + "` to the queue, queue now has a total of `" + (startIndex + l.size() - 1) + "` tracks.").queue();
channel.sendMessage((startIndex == 1 ? "Playing `" : "First track: `") + l.get(0).getInfo().title + "` (" + l.get(0).getUserData(TrackData.class).getFormattedDuration() + ") [`" + startIndex + "`].").queue();
}
resFuture.complete(new CommandResult(CommandResultType.SUCCESS));
} catch (UnsupportedOperationException e) {
resFuture.complete(new CommandResult(CommandResultType.FAILURE, e.getMessage()));
}
}, (ex) -> resFuture.complete(ex.getStackTrace()[0].getMethodName().equals("readPlaylistName") ? new CommandResult(CommandResultType.FAILURE, "Cannot read the playlist specified. Is it private?") : new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex))));
return resFuture;
}
private String fetchUrlFromSearch(String search, String type) throws IOException {
if (type.startsWith("youtube")) {
type = type.substring(8, type.length());
if (type.equals("video")) {
return "ytsearch:" + search;
} else { //scrape with our own code since YoutubeSearchProvider only scrapes videos
//NOADD check if theres a way to search for playlists and vids at the same time //using different commands now
try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) {
URI url = new URIBuilder("https://www.youtube.com/results") //sp is filter param, EgIQAw== is the base64 encoding of playlist option
.addParameter("search_query", search).addParameter("sp", "EgIQAw==").build();
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(url))) { //connection code borrowed from lavaplayer's YoutubeSearchProvider
int statusCode = response.getStatusLine().getStatusCode();
if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new IOException("Invalid status code for search response: " + statusCode);
}
//start parsing
String data = IOUtils.toString(response.getEntity().getContent(), "UTF-8");
data = data.substring(data.indexOf("var ytInitialData = ") + 20);
JSONObject result = new JSONObject(new JSONTokener(data.substring(0, data.indexOf(";"))))
.getJSONObject("contents").getJSONObject("twoColumnSearchResultsRenderer").getJSONObject("primaryContents")
.getJSONObject("sectionListRenderer").getJSONArray("contents").getJSONObject(0).getJSONObject("itemSectionRenderer");
//iterate to get first playlist
for(Object obj : result.getJSONArray("contents")) {
JSONObject renderer = (JSONObject) obj;
if(renderer.has("playlistRenderer")) {
return "https://www.youtube.com" + renderer.getJSONObject("playlistRenderer").getJSONObject("navigationEndpoint")
.getJSONObject("commandMetadata").getJSONObject("webCommandMetadata").getString("url"); //return first found
}
}
return null; //if no result
}
} catch (URISyntaxException e) {
e.printStackTrace(); //should be unreachable
}
}
} else if (type.equals("soundcloud")) {
return "scsearch:" + search;
}
throw new UnsupportedOperationException("This provider is not implemented yet!");
}
//package private
void load(Member user, String url, BiConsumer> resultHandler, Consumer exceptionally) {
playerManager.loadItemOrdered(getGuildMusicManager(user.getGuild()), url, new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
try {
track.setUserData(new TrackData(track.getInfo().uri, user.getUser(), track.getDuration()));
resultHandler.accept(null, Arrays.asList(track));
} catch (Exception e) {
exceptionally.accept(e); //so i dont lose my sanity over silenced errors
}
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
try {
if(playlist.getTracks().size() == 0) { //somehow its possible; do the same as noResult()
if(url.startsWith("ytsearch:")) load(user, url.replaceFirst("yt", "sc"), resultHandler, exceptionally); //searches in soundcloud, since apparently yt pretty frequently returns no result
else resultHandler.accept(null, new ArrayList<>());
return;
}
String plName = playlist.getName();
List tracks = playlist.getTracks();
String plId = "";
if(playlist.isSearchResult()) {
tracks = tracks.subList(0, 1); //only get first result if search
plName = null; //no actual playlist name
} else {
if(url.contains("://soundcloud.com") || url.contains("://www.youtube.com")) //add pl id if is playlist
plId = url.contains("://soundcloud.com") ? "?in=" + url.split("soundcloud.com/")[1] : "&list=" + url.split("list=")[1].split("&")[0];
}
for (AudioTrack track : tracks) //TODO tell users that we skipped some tracks?
if(track != null) track.setUserData(new TrackData(track.getInfo().uri + plId, user.getUser(), track.getDuration()));
if (playlist.getSelectedTrack() != null)
tracks.add(0, tracks.remove(tracks.indexOf(playlist.getSelectedTrack()))); //shift selected track to first track
resultHandler.accept(plName, tracks.stream().filter(t -> t != null).collect(Collectors.toList()));
} catch (Exception e) {
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() != null && exception.getMessage().contains("Unknown file format.")) && url.contains("open.spotify.com")) {
resultHandler.accept(null, new ArrayList<>()); //TODO TEMPORARY FIX
} else {
exceptionally.accept(exception.getCause() != null ? exception.getCause() : exception);
}
}
});
}
public int queueTracks(List tracks, Member user) throws UnsupportedOperationException {
Guild guild = user.getGuild();
GuildMusicManager musicManager = getGuildMusicManager(guild);
int startIndex = musicManager.scheduler.getQueueSize() + (musicManager.player.getPlayingTrack() != null ? 1 : 0);
if (user.getVoiceState().inAudioChannel()) {
if (!guild.getAudioManager().isConnected()) {
VoiceChannel voice = user.getVoiceState().getChannel().asVoiceChannel();
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 MessageCreateBuilder 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);
MessageCreateBuilder smsg = new MessageCreateBuilder();
String fpos = MiscUtils.convertMillis(track.getPosition());
String fdur = data.getFormattedDuration();
smsg.addContent((index == 1 ? "Current" : MiscUtils.ordinal(index)) + " track: `" + track.getInfo().title + "` (" + fpos + "/" + (track.getDuration() == Long.MAX_VALUE ? "???" : fdur) + ")\n");
smsg.addContent("Author: " + track.getInfo().author + "\n");
smsg.addContent("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.addContent("URL: " + data.getUrl() + (timeTag.isEmpty() ? "" : timeTag + TimeUnit.MILLISECONDS.toSeconds(track.getPosition())));
return smsg;
}
//TODO migrate to embed?
public MessageCreateBuilder queueCheck(Guild guild, int page) throws IllegalArgumentException {
GuildMusicManager mm = getGuildMusicManager(guild);
AudioTrack playing = mm.player.getPlayingTrack();
if(playing == null) return null;
MessageCreateBuilder smsg = new MessageCreateBuilder();
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.addContent("The current queue (page " + page + "/" + maxPage + "): \n");
if (mm.scheduler.loop != null) {
smsg.addContent("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.addContent("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.addContent("[" + times + "]: `" + track.getInfo().title + "` (" + (track.getDuration() == Long.MAX_VALUE ? "N/A" : data.getFormattedDuration()) + ") requested by `" + data.getUserWithDiscriminator() + "`\n");
}
if(maxPage > page) smsg.addContent("\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.closeAudioConnection();
DiscordBot.lastMusicCmd.get(s).sendMessage("The music bot is going into maintenance and it will now disconnect. Sorry for the inconvenience.").queue();
}
});
musicManagers = null; //so further operations wont be possible even if i forgot to set this instance to null
playerManager.shutdown();
ex.shutdown();
}
}
diff --git a/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java
index 0f9d59c..4e263ae 100644
--- a/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java
+++ b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java
@@ -1,316 +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.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;
+import dev.lavalink.youtube.YoutubeAudioSourceManager;
+
/**
* 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(AudioPlayerManager manager, AudioReference reference) {
if(spotifyApi == null) return null; //disabled due to broken api
try {
URL url = new URL(reference.identifier);
if (!StringUtils.equals(url.getHost(), SPOTIFY_DOMAIN)) {
return null;
}
AudioItem audioItem = null;
audioItem = handleAsPlaylist(url, manager);
if (audioItem == null) {
audioItem = handleAsTrack(url, manager);
}
return audioItem;
} catch (MalformedURLException e) {
return null;
}
}
private AudioTrack handleAsTrack(URL url, AudioPlayerManager man) {
Path path = Paths.get(url.getPath());
if (path.getNameCount() < 2) {
return null;
}
if (!StringUtils.equals(path.getName(0).toString(), "track")) {
return null;
}
String trackId = path.getName(1).toString();
TrackSimplified track;
try {
Track t = spotifyApi.getTrack(trackId).build().execute();
track = new TrackSimplified.Builder().setArtists(t.getArtists()).setName(t.getName()).setDurationMs(t.getDurationMs()).build();
} catch (IOException | SpotifyWebApiException | ParseException e) {
throw new IllegalStateException("Unable to fetch track from Spotify API.", e);
}
return getAudioTracks(Arrays.asList(track), man).get(0);
}
private BasicAudioPlaylist handleAsPlaylist(URL url, AudioPlayerManager man) {
String playlistKey;
try {
playlistKey = extractPlaylistId(url);
} catch (IllegalArgumentException e) {
return null;
}
String name;
List tracks;
try {
Playlist playlist = spotifyApi.getPlaylist(playlistKey)
.build().execute();
name = playlist.getName();
tracks = getAllPlaylistTracks(playlist).stream().map(pt -> pt.getTrack())
.filter(pt -> pt.getType() == ModelObjectType.TRACK).map(pt -> {
Track t = (Track) pt; //it doesnt have any methods to translate Track to TrackSimplified, so i had to do this; it only uses 3 params anyways
return new TrackSimplified.Builder().setArtists(t.getArtists()).setName(t.getName()).setDurationMs(t.getDurationMs()).build();
}).collect(Collectors.toList());
} catch (IOException | SpotifyWebApiException | ParseException e) {
if(e instanceof NotFoundException) { //try searching as album
try {
Album album = spotifyApi.getAlbum(playlistKey).build().execute();
name = album.getName();
tracks = getAllAlbumTracks(album);
} catch (ParseException | SpotifyWebApiException | IOException e1) {
throw new IllegalStateException("Unable to fetch playlist from Spotify API.", e1);
}
} else {
throw new IllegalStateException("Unable to fetch playlist from Spotify API.", e);
}
}
List audioTracks = getAudioTracks(tracks, man);
return new BasicAudioPlaylist(name, audioTracks, null, false);
}
private List getAllPlaylistTracks(Playlist playlist) {
List playlistTracks = new ArrayList<>();
Paging currentPage = playlist.getTracks();
do {
playlistTracks.addAll(Arrays.asList(currentPage.getItems()));
if (currentPage.getNext() == null) {
currentPage = null;
} else {
try {
URI nextPageUri = new URI(currentPage.getNext());
List queryPairs = URLEncodedUtils.parse(nextPageUri, StandardCharsets.UTF_8);
GetPlaylistsItemsRequest.Builder b = spotifyApi.getPlaylistsItems(playlist.getId());
for (NameValuePair queryPair : queryPairs) {
b = b.setBodyParameter(queryPair.getName(), queryPair.getValue());
}
currentPage = b.build().execute();
} catch (IOException | SpotifyWebApiException | ParseException e) {
throw new IllegalStateException("Unable to query Spotify for playlist tracks.", e);
} catch (URISyntaxException e) {
throw new IllegalStateException("Spotify returned an invalid 'next page' URI.", e);
}
}
} while (currentPage != null);
return playlistTracks;
}
private List getAllAlbumTracks(Album album) {
List albumTracks = new ArrayList<>();
Paging currentPage = album.getTracks();
do {
albumTracks.addAll(Arrays.asList(currentPage.getItems()));
if (currentPage.getNext() == null) {
currentPage = null;
} else {
try {
URI nextPageUri = new URI(currentPage.getNext());
List queryPairs = URLEncodedUtils.parse(nextPageUri, StandardCharsets.UTF_8);
GetAlbumsTracksRequest.Builder b = spotifyApi.getAlbumsTracks(album.getId());
for (NameValuePair queryPair : queryPairs) {
b = b.setBodyParameter(queryPair.getName(), queryPair.getValue());
}
currentPage = b.build().execute();
} catch (IOException | SpotifyWebApiException | ParseException e) {
throw new IllegalStateException("Unable to query Spotify for album tracks.", e);
} catch (URISyntaxException e) {
throw new IllegalStateException("Spotify returned an invalid 'next page' URI.", e);
}
}
} while (currentPage != null);
return albumTracks;
}
private String extractPlaylistId(URL url) {
Path path = Paths.get(url.getPath());
if (path.getNameCount() < EXPECTED_PATH_COMPONENTS) {
throw new IllegalArgumentException("Not enough path components.");
}
if (!Arrays.asList("playlist", "album").contains(path.getName(0).toString())) {
throw new IllegalArgumentException("URL doesn't appear to be a playlist.");
}
String playlistId = path.getName(1).toString();
if (StringUtils.isBlank(playlistId)) {
throw new IllegalArgumentException("Playlist ID is blank.");
}
return playlistId;
}
@Override
public boolean isTrackEncodable(AudioTrack track) {
return false;
}
@Override
public void encodeTrack(AudioTrack track, DataOutput output) throws IOException {
throw new UnsupportedOperationException("encodeTrack is unsupported.");
}
@Override
public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) throws IOException {
throw new UnsupportedOperationException("decodeTrack is unsupported.");
}
@Override
public void shutdown() {
}
private List getAudioTracks(List tracks, AudioPlayerManager manager) {
return tracks.parallelStream().map(track -> { //parallelStream made a world of difference in loading times lmao
String artist = track.getArtists().length < 1 ? "" : track.getArtists()[0].getName();
AudioItem item = this.manager.loadItem(manager, new AudioReference("ytsearch:" + artist + " " + track.getName(), null));
if (item instanceof AudioPlaylist) {
AudioPlaylist audioPlaylist = (AudioPlaylist) item;
// The number of matches is limited to reduce the chances of matching against
// less than optimal results.
// The best match is the one that has the smallest track duration delta.
YoutubeAudioTrack bestMatch = audioPlaylist.getTracks().stream().limit(3)
.map(t -> (YoutubeAudioTrack) t).min((o1, o2) -> {
long o1TimeDelta = Math.abs(o1.getDuration() - track.getDurationMs());
long o2TimeDelta = Math.abs(o2.getDuration() - track.getDurationMs());
return (int) (o1TimeDelta - o2TimeDelta);
}).orElse(null);
return bestMatch;
} else if (item instanceof YoutubeAudioTrack) {
return (YoutubeAudioTrack) item;
} else if (item instanceof AudioReference) { //no results; retry once more
System.out.println("Spotify Source Manager: Retry needed for " + track.getName());
item = this.manager.loadItem(manager, new AudioReference("ytsearch:" + artist + " " + track.getName(), null));
if(item instanceof AudioPlaylist) item = ((AudioPlaylist) item).getTracks().get(0); //cba doing the best match lmao
else if(!(item instanceof YoutubeAudioTrack)) return null; //if not playlist and track return null again
return (YoutubeAudioTrack) item;
} else {
throw new IllegalArgumentException("Unknown AudioItem"); //should never throw
}
}).collect(Collectors.toList());
}
}