diff --git a/pom.xml b/pom.xml index f7049409..97d9c501 100644 --- a/pom.xml +++ b/pom.xml @@ -40,7 +40,7 @@ com.sedmelluq lavaplayer - 1.3.19 + 1.3.20 org.apache.commons @@ -75,7 +75,7 @@ net.dv8tion JDA - 3.8.3_462 + 4.0.0_39 compile @@ -96,7 +96,7 @@ se.michaelthelin.spotify spotify-web-api-java - 2.1.2 + 2.2.0 org.hibernate diff --git a/resources/current-version.txt b/resources/current-version.txt index 460b6d89..308b6faa 100644 --- a/resources/current-version.txt +++ b/resources/current-version.txt @@ -1 +1 @@ -1.6.1.4 \ No newline at end of file +1.6.2 \ No newline at end of file diff --git a/resources/versions.xml b/resources/versions.xml index f6e8b042..48c223c3 100644 --- a/resources/versions.xml +++ b/resources/versions.xml @@ -1,6 +1,19 @@ - + + **new command parser that is smarter at interpreting arguments** + - makes using arguments less strict and inline arguments may now be used wherever you want in the command and they are treated as regular arguments with the input to their right as value + - *meaning `insert track $to listName $at position` could now also be written `insert track $at position $to listName` or even `insert $to=listName $at=position track`* + - enables using escape characters and quotes to escape meta characters e.g. `play $spotify \$trackname` or `play $spotify "$trackname"` + - enables argument values containing whitespace by using quotes like `command $arg="some value"` + **enabled selecting several options comma separated when asked a question** + **added an option to select all options when asked a question by certain commands** + **added property to customise argument prefix** + **added monthly charts to charts command** + **help command examples now use the custom prefixes** + **upgrade to JDA 4** + + make Spotify redirect smarter add some handling for rare cases when PlaylistTracks have a null track diff --git a/resources/xml-contributions/commandInterceptors.xml b/resources/xml-contributions/commandInterceptors.xml index d330cbc5..e43365c4 100644 --- a/resources/xml-contributions/commandInterceptors.xml +++ b/resources/xml-contributions/commandInterceptors.xml @@ -3,8 +3,11 @@ - + + + + - + \ No newline at end of file diff --git a/resources/xml-contributions/commands.xml b/resources/xml-contributions/commands.xml index 18f3b1d5..c90e9562 100644 --- a/resources/xml-contributions/commands.xml +++ b/resources/xml-contributions/commands.xml @@ -2,110 +2,111 @@ - $botify add $spotify $own from the inside $to my list. - $botify add $queue my list - $botify add http://someurl.com $to linkin park + %sadd $spotify $own from the inside $to my list. + %sadd $queue my list + %sadd http://someurl.com $to linkin park - $botify answer 2 + %sanswer 2 + %sanswer 2,4,6 - $botify create my list + %screate my list - $botify delete my list + %sdelete my list - $botify export my list + %sexport my list - $botify help play + %shelp play - $botify login + %slogin - $botify play - $botify play numb - $botify play from the inside artist:linkin park album:meteora - $botify play someurl.com - $botify play $youtube youtube rewind 2018 - $botify play $youtube $list $limit=5 memes - $botify play $youtube $list important videos - $botify play $spotify $list this is linkin park - $botify play $spotify $list $own goat + %splay + %splay numb + %splay from the inside artist:linkin park album:meteora + %splay someurl.com + %splay $youtube youtube rewind 2018 + %splay $youtube $list $limit=5 memes + %splay $youtube $list important videos + %splay $spotify $list this is linkin park + %splay $spotify $list $own goat - $botify queue - $botify queue numb - $botify queue from the inside artist:linkin park album:meteora - $botify queue $list my list - $botify queue $spotify $list this is linkin park - $botify queue $list $spotify $own favs - $botify queue $youtube $list memes - $botify queue $youtube $list $limit=5 memes + %squeue + %squeue numb + %squeue from the inside artist:linkin park album:meteora + %squeue $list my list + %squeue $spotify $list this is linkin park + %squeue $list $spotify $own favs + %squeue $youtube $list memes + %squeue $youtube $list $limit=5 memes - $botify remove numb $from my list - $botify remove http://someurl.com/video1 $from my list - $botify remove $index 3 $from my list - $botify remove $index 13-19 $from my list + %sremove numb $from my list + %sremove http://someurl.com/video1 $from my list + %sremove $index 3 $from my list + %sremove $index 13-19 $from my list - $botify rename Patrice + %srename Patrice - $botify repeat - $botify repeat $one + %srepeat + %srepeat $one - $botify rewind - $botify rewind 6 + %srewind + %srewind 6 - $botify search $list - $botify search $list my list - $botify search $spotify numb artist:linkin park album:meteora - $botify search $spotify $list this is linkin park - $botify search $youtube $list memes - $botify search $youtube $list $limit=6 memes + %ssearch $list + %ssearch $list my list + %ssearch $spotify numb artist:linkin park album:meteora + %ssearch $spotify $list this is linkin park + %ssearch $youtube $list memes + %ssearch $youtube $list $limit=6 memes - $botify skip - $botify skip 6 + %sskip + %sskip 6 - $botify upload my list + %supload my list - $botify permission $grant play $to playbackmanager - $botify permission $deny add $for playbackmanager - $botify permission $clear shuffle - $botify permission $grant $all manager - $botify permission $grant $category playback $to playbackmanager - $botify permission $lock add + %spermission $grant play $to playbackmanager + %spermission $deny add $for playbackmanager + %spermission $clear shuffle + %spermission $grant $all manager + %spermission $grant $category playback $to playbackmanager + %spermission $lock add @@ -115,49 +116,49 @@ description="Clear the current queue of all tracks (except the currently playing track)."/> - $botify forward 110 - $botify forward $minutes 2 + %sforward 110 + %sforward $minutes 2 - $botify reverse 110 - $botify reverse $minutes 2 + %sreverse 110 + %sreverse $minutes 2 - $botify preset add %s $to favs $as fav - $botify preset play $list %s $as pl - $botify preset forward %s $as ff - $botify preset search $list $as list - $botify preset play $spotify $own $list %s $as psol - $botify preset - $botify preset $delete psol + description="Create or delete a command preset or show all saved presets. Command presets can be used as shortcuts for lengthy commands or creating an alias for a command. Presets han hold one variable marked by "%%s" that may be assigned a value when using the preset. Syntax to create a preset: [preset] $as [name]. Mind that if your preset contains arguments you either need to put the preset in quotation marks or escape the argument prefixes using the escape character '\\'; see the examples for references."> + %spreset "add %%s $to favs" $as fav + %spreset "play $list %%s" $as pl + %spreset forward %%s $as ff + %spreset search \\$list $as list + %spreset "play $spotify $own $list %%s" $as psol + %spreset + %spreset $delete psol - $botify prefix . + %sprefix . - $botify move 5 $to 10 $on my list - $botify move 4-6 $to 10 $on my list - $botify move 14-16 $to 10 $on my list + description="Move one or several items in a botify playlist to a different index. When moving items down the playlist the items will end up behind the track that is currently at the target index or before when moving items upwards. When entering an index range to move it includes the start and end index. Indices are human, meaning they start at 1. To view full playlists with all indices search the list (%ssearch $list my list) and then click the view full list link."> + %smove 5 $to 10 $on my list + %smove 4-6 $to 10 $on my list + %smove 14-16 $to 10 $on my list - $botify insert $spotify $own $list goat $to my list $at 1 - $botify insert $youtube $list favs $to my list $at 10 + %sinsert $spotify $own $list goat $to my list $at 1 + %sinsert $youtube $list favs $to my list $at 10 - $botify property - $botify property color $set blue - $botify property color $set #1DB954 - $botify property $toggle playback notification - $botify property default source $set youtube - $botify property default list source $set local + %sproperty + %sproperty color $set blue + %sproperty color $set #1DB954 + %sproperty $toggle playback notification + %sproperty default source $set youtube + %sproperty default list source $set local diff --git a/resources/xml-contributions/guildProperties.xml b/resources/xml-contributions/guildProperties.xml index 853b1ab4..0e77caef 100644 --- a/resources/xml-contributions/guildProperties.xml +++ b/resources/xml-contributions/guildProperties.xml @@ -7,4 +7,5 @@ + \ No newline at end of file diff --git a/src/main/java/net/robinfriedli/botify/Botify.java b/src/main/java/net/robinfriedli/botify/Botify.java index d44d670e..8ab73769 100644 --- a/src/main/java/net/robinfriedli/botify/Botify.java +++ b/src/main/java/net/robinfriedli/botify/Botify.java @@ -6,9 +6,9 @@ import org.slf4j.LoggerFactory; import com.wrapper.spotify.SpotifyApi; -import net.dv8tion.jda.core.JDA; -import net.dv8tion.jda.core.OnlineStatus; -import net.dv8tion.jda.core.hooks.ListenerAdapter; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.OnlineStatus; +import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.robinfriedli.botify.audio.AudioManager; import net.robinfriedli.botify.command.CommandManager; import net.robinfriedli.botify.command.SecurityManager; @@ -105,7 +105,7 @@ public static void shutdownListeners() { * method waits for those threads to finish, causing a deadlock. * * @param millisToWait time to wait for pending actions to complete in milliseconds, after this time the bot will - * quit either way + * quit either way */ public static void shutdown(long millisToWait) { Botify botify = get(); diff --git a/src/main/java/net/robinfriedli/botify/audio/AudioManager.java b/src/main/java/net/robinfriedli/botify/audio/AudioManager.java index ceb61f0a..85588e69 100644 --- a/src/main/java/net/robinfriedli/botify/audio/AudioManager.java +++ b/src/main/java/net/robinfriedli/botify/audio/AudioManager.java @@ -13,10 +13,11 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers; -import net.dv8tion.jda.core.entities.Guild; -import net.dv8tion.jda.core.entities.Message; -import net.dv8tion.jda.core.entities.VoiceChannel; -import net.dv8tion.jda.core.exceptions.InsufficientPermissionException; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.VoiceChannel; +import net.dv8tion.jda.api.exceptions.InsufficientPermissionException; +import net.robinfriedli.botify.audio.spotify.SpotifyService; import net.robinfriedli.botify.audio.youtube.YouTubeService; import net.robinfriedli.botify.command.CommandManager; import net.robinfriedli.botify.command.widgets.NowPlayingWidget; @@ -55,7 +56,15 @@ public AudioManager(YouTubeService youTubeService, SessionFactory sessionFactory guildManager.setAudioManager(this); } - public void playTrack(Guild guild, @Nullable VoiceChannel channel) { + public void startPlayback(Guild guild, @Nullable VoiceChannel channel) { + playTrack(guild, channel, false); + } + + public void startOrResumePlayback(Guild guild, @Nullable VoiceChannel channel) { + playTrack(guild, channel, true); + } + + public void playTrack(Guild guild, @Nullable VoiceChannel channel, boolean resumePaused) { AudioPlayback playback = getPlaybackForGuild(guild); if (channel != null) { @@ -64,15 +73,12 @@ public void playTrack(Guild guild, @Nullable VoiceChannel channel) { throw new InvalidCommandException("Not in a voice channel"); } - QueueIterator currentQueueIterator = playback.getCurrentQueueIterator(); - Playable current = playback.getAudioQueue().getCurrent(); - if (!playback.isPaused() - || (currentQueueIterator != null && !current.matches(currentQueueIterator.getCurrentlyPlaying()))) { + if (playback.isPaused() && resumePaused) { + playback.unpause(); + } else { QueueIterator queueIterator = new QueueIterator(playback, this); playback.setCurrentQueueIterator(queueIterator); queueIterator.playNext(); - } else { - playback.unpause(); } } @@ -97,8 +103,8 @@ public AudioPlayerManager getPlayerManager() { return playerManager; } - public PlayableFactory createPlayableFactory(Guild guild) { - return new PlayableFactory(urlAudioLoader, youTubeService, guildManager.getContextForGuild(guild).getTrackLoadingExecutor()); + public PlayableFactory createPlayableFactory(Guild guild, SpotifyService spotifyService) { + return new PlayableFactory(spotifyService, urlAudioLoader, youTubeService, guildManager.getContextForGuild(guild).getTrackLoadingExecutor()); } void createHistoryEntry(Playable playable, Guild guild) { diff --git a/src/main/java/net/robinfriedli/botify/audio/AudioPlayback.java b/src/main/java/net/robinfriedli/botify/audio/AudioPlayback.java index 5d5ac30f..2f50f8d5 100644 --- a/src/main/java/net/robinfriedli/botify/audio/AudioPlayback.java +++ b/src/main/java/net/robinfriedli/botify/audio/AudioPlayback.java @@ -5,10 +5,10 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import net.dv8tion.jda.core.entities.Guild; -import net.dv8tion.jda.core.entities.Message; -import net.dv8tion.jda.core.entities.MessageChannel; -import net.dv8tion.jda.core.entities.VoiceChannel; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageChannel; +import net.dv8tion.jda.api.entities.VoiceChannel; /** * There is exactly one AudioPlayback per guild instantiated when initializing the guild. This class holds all information diff --git a/src/main/java/net/robinfriedli/botify/audio/AudioPlayerSendHandler.java b/src/main/java/net/robinfriedli/botify/audio/AudioPlayerSendHandler.java index 17146aa3..df73bb58 100644 --- a/src/main/java/net/robinfriedli/botify/audio/AudioPlayerSendHandler.java +++ b/src/main/java/net/robinfriedli/botify/audio/AudioPlayerSendHandler.java @@ -1,10 +1,13 @@ package net.robinfriedli.botify.audio; +import java.nio.ByteBuffer; + import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; -import net.dv8tion.jda.core.audio.AudioSendHandler; +import net.dv8tion.jda.api.audio.AudioSendHandler; public class AudioPlayerSendHandler implements AudioSendHandler { + private final AudioPlayer audioPlayer; private AudioFrame lastFrame; @@ -19,8 +22,8 @@ public boolean canProvide() { } @Override - public byte[] provide20MsAudio() { - return lastFrame.getData(); + public ByteBuffer provide20MsAudio() { + return ByteBuffer.wrap(lastFrame.getData()); } @Override diff --git a/src/main/java/net/robinfriedli/botify/audio/AudioQueue.java b/src/main/java/net/robinfriedli/botify/audio/AudioQueue.java index abf007a7..2cf6a0cc 100644 --- a/src/main/java/net/robinfriedli/botify/audio/AudioQueue.java +++ b/src/main/java/net/robinfriedli/botify/audio/AudioQueue.java @@ -8,8 +8,8 @@ import java.util.stream.IntStream; import com.google.common.collect.Lists; -import net.dv8tion.jda.core.EmbedBuilder; -import net.dv8tion.jda.core.entities.Guild; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; import net.robinfriedli.botify.Botify; import net.robinfriedli.botify.discord.properties.ColorSchemeProperty; import net.robinfriedli.botify.entities.GuildSpecification; @@ -167,10 +167,10 @@ public EmbedBuilder buildMessageEmbed(AudioPlayback playback, Guild guild) { String currentPosition = Util.normalizeMillis(playback.getCurrentPositionMs()); Playable current = getCurrent(); - String duration = Util.normalizeMillis(current.getDurationMsInterruptible()); + String duration = Util.normalizeMillis(current.durationMs()); embedBuilder.addField( "Current", - "| " + current.getDisplayInterruptible() + " - " + currentPosition + " / " + duration, + "| " + current.display() + " - " + currentPosition + " / " + duration, false ); @@ -278,7 +278,7 @@ public void clear() { * Clear the current tracks in this queue * * @param retainCurrent keeps the track that is referenced by the currentTrack index in the queue, this is used - * when the track is currently being played + * when the track is currently being played */ public void clear(boolean retainCurrent) { if (!isEmpty() && retainCurrent) { @@ -367,7 +367,7 @@ public void randomize() { * Generates the random queue order when enabling the shuffle option * * @param protectCurrent if true this makes sure that the current track will remain in the same position, used - * when the playback is currently playing + * when the playback is currently playing */ public void randomize(boolean protectCurrent) { randomizedOrder.clear(); @@ -420,8 +420,8 @@ private void appendIcon(StringBuilder builder, String unicode, boolean enabled) } private void appendPlayable(StringBuilder trackListBuilder, Playable playable) { - String display = playable.getDisplayInterruptible(); - long durationMs = playable.getDurationMsInterruptible(); + String display = playable.display(); + long durationMs = playable.durationMs(); trackListBuilder.append("| ").append(display).append(" - ").append(Util.normalizeMillis(durationMs)).append(System.lineSeparator()); } diff --git a/src/main/java/net/robinfriedli/botify/audio/Playable.java b/src/main/java/net/robinfriedli/botify/audio/Playable.java index 85306c37..b568d13f 100644 --- a/src/main/java/net/robinfriedli/botify/audio/Playable.java +++ b/src/main/java/net/robinfriedli/botify/audio/Playable.java @@ -6,9 +6,10 @@ import javax.annotation.Nullable; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import net.dv8tion.jda.core.entities.User; +import net.dv8tion.jda.api.entities.User; import net.robinfriedli.botify.entities.Playlist; import net.robinfriedli.botify.entities.PlaylistItem; +import net.robinfriedli.botify.exceptions.UnavailableResourceException; import org.hibernate.Session; /** @@ -18,81 +19,117 @@ public interface Playable { /** * @return The url of the music file to stream - * @throws InterruptedException if the thread loading the data asynchronously gets interrupted + * @throws UnavailableResourceException if loading the item was cancelled due to being unavailable or cancelled */ - String getPlaybackUrl() throws InterruptedException; + String getPlaybackUrl() throws UnavailableResourceException; /** * @return an id that uniquely identifies this playable together with {@link #getSource()} */ - String getId() throws InterruptedException; + String getId() throws UnavailableResourceException; /** * @return The title of the Playable. For Spotify it's the track name, for YouTube it's the video title and for other * URLs it's either the tile or the URL depending on whether or not a title could be found - * @throws InterruptedException if the thread loading the data asynchronously gets interrupted + * @throws UnavailableResourceException if loading the item was cancelled due to being unavailable or cancelled */ - String getDisplay() throws InterruptedException; + String getDisplay() throws UnavailableResourceException; /** - * @return the display of the Playable, showing interrupted Playables as "[UNAVAILABLE]" + * @return the display of the Playable, showing cancelled Playables as "[UNAVAILABLE]" */ - default String getDisplayInterruptible() { + default String display() { try { return getDisplay(); - } catch (InterruptedException e) { + } catch (UnavailableResourceException e) { return "[UNAVAILABLE]"; } } /** * @return the display of the Playable - * @throws InterruptedException if the thread loading the data asynchronously gets interrupted - * @throws TimeoutException if the data is not loaded in time + * @throws UnavailableResourceException if loading the item was cancelled due to being unavailable or cancelled + * @throws TimeoutException if the data is not loaded in time */ - String getDisplay(long timeOut, TimeUnit unit) throws InterruptedException, TimeoutException; + String getDisplay(long timeOut, TimeUnit unit) throws UnavailableResourceException, TimeoutException; /** - * @return the display of the Playable, showing interrupted Playables as "[UNAVAILABLE]" and Playables that couldn't + * @return the display of the Playable, showing cancelled Playables as "[UNAVAILABLE]" and Playables that couldn't * load in time as "Loading..." */ - default String getDisplayInterruptible(long timeOut, TimeUnit unit) { + default String display(long timeOut, TimeUnit unit) { try { return getDisplay(timeOut, unit); - } catch (InterruptedException e) { + } catch (UnavailableResourceException e) { return "[UNAVAILABLE]"; } catch (TimeoutException e) { return "Loading..."; } } + /** + * Return the display of the playable getting the current value without waiting for completion of the value, + * returning the provided alternativeValue instead if the playable is not completed. + * + * @param alternativeValue the value to return instead if the value has not been loaded yet + * @return the display + * @throws UnavailableResourceException if loading the item was cancelled due to being unavailable or cancelled + */ + String getDisplayNow(String alternativeValue) throws UnavailableResourceException; + + default String getDisplayNow() { + try { + return getDisplayNow("Loading..."); + } catch (UnavailableResourceException e) { + return "[UNAVAILABLE]"; + } + } + /** * @return The duration of the audio track in milliseconds - * @throws InterruptedException if loading the data asynchronously was interrupted + * @throws UnavailableResourceException if loading the item was cancelled due to being unavailable or cancelled */ - long getDurationMs() throws InterruptedException; + long getDurationMs() throws UnavailableResourceException; /** - * @return The duration of the audio track in milliseconds or 0 if loading the Playable was interrupted + * @return The duration of the audio track in milliseconds or 0 if loading the Playable was cancelled */ - default long getDurationMsInterruptible() { + default long durationMs() { try { return getDurationMs(); - } catch (InterruptedException e) { + } catch (UnavailableResourceException e) { return 0; } } /** - * @return The duration of the audio track in milliseconds or 0 if loading the Playable was interrupted or did + * @return The duration of the audio track in milliseconds or 0 if loading the Playable was cancelled or did * not load in time */ - long getDurationMs(long timeOut, TimeUnit unit) throws InterruptedException, TimeoutException; + long getDurationMs(long timeOut, TimeUnit unit) throws UnavailableResourceException, TimeoutException; - default long getDurationMsInterruptible(long timeOut, TimeUnit unit) { + default long durationMs(long timeOut, TimeUnit unit) { try { return getDurationMs(timeOut, unit); - } catch (InterruptedException | TimeoutException e) { + } catch (UnavailableResourceException | TimeoutException e) { + return 0; + } + } + + /** + * Get the duration now without waiting for the playable to finish, returning the alternativeValue instead if not + * done + * + * @param alternativeValue the value to return if the value has not been loaded yet + * @return the display + * @throws UnavailableResourceException if loading the item was cancelled due to being unavailable or cancelled + */ + long getDurationNow(long alternativeValue) throws UnavailableResourceException; + + default long getDurationNow() { + try { + return getDurationNow(0); + } catch (UnavailableResourceException e) { return 0; } } @@ -101,7 +138,7 @@ default long getDurationMsInterruptible(long timeOut, TimeUnit unit) { * Exports this playable as a persistable {@link PlaylistItem} * * @param playlist the playlist this item will be a part of - * @param user the user that added the item + * @param user the user that added the item * @return the create item (not persisted yet) */ PlaylistItem export(Playlist playlist, User user, Session session); @@ -125,17 +162,4 @@ default long getDurationMsInterruptible(long timeOut, TimeUnit unit) { */ void setCached(AudioTrack audioTrack); - default boolean matches(Playable playable) { - if (playable == null) { - return false; - } - - try { - return getSource().equals(playable.getSource()) && getId().equals(playable.getId()); - } catch (InterruptedException ignored) { - } - - return false; - } - } diff --git a/src/main/java/net/robinfriedli/botify/audio/PlayableFactory.java b/src/main/java/net/robinfriedli/botify/audio/PlayableFactory.java index 60e10abc..33970cea 100644 --- a/src/main/java/net/robinfriedli/botify/audio/PlayableFactory.java +++ b/src/main/java/net/robinfriedli/botify/audio/PlayableFactory.java @@ -18,6 +18,8 @@ import com.wrapper.spotify.exceptions.SpotifyWebApiException; import com.wrapper.spotify.exceptions.detailed.BadRequestException; import com.wrapper.spotify.exceptions.detailed.NotFoundException; +import com.wrapper.spotify.model_objects.specification.AlbumSimplified; +import com.wrapper.spotify.model_objects.specification.PlaylistSimplified; import com.wrapper.spotify.model_objects.specification.Track; import net.robinfriedli.botify.audio.spotify.SpotifyService; import net.robinfriedli.botify.audio.spotify.TrackWrapper; @@ -26,6 +28,7 @@ import net.robinfriedli.botify.audio.youtube.YouTubeService; import net.robinfriedli.botify.audio.youtube.YouTubeVideo; import net.robinfriedli.botify.concurrent.GuildTrackLoadingExecutor; +import net.robinfriedli.botify.concurrent.Invoker; import net.robinfriedli.botify.entities.UrlTrack; import net.robinfriedli.botify.exceptions.InvalidCommandException; import net.robinfriedli.botify.exceptions.NoResultsFoundException; @@ -38,14 +41,18 @@ */ public class PlayableFactory { + private final SpotifyService spotifyService; private final UrlAudioLoader urlAudioLoader; private final YouTubeService youTubeService; private final GuildTrackLoadingExecutor trackLoadingExecutor; + private final Invoker invoker; - public PlayableFactory(UrlAudioLoader urlAudioLoader, YouTubeService youTubeService, GuildTrackLoadingExecutor trackLoadingExecutor) { + public PlayableFactory(SpotifyService spotifyService, UrlAudioLoader urlAudioLoader, YouTubeService youTubeService, GuildTrackLoadingExecutor trackLoadingExecutor) { + this.spotifyService = spotifyService; this.urlAudioLoader = urlAudioLoader; this.youTubeService = youTubeService; this.trackLoadingExecutor = trackLoadingExecutor; + this.invoker = new Invoker(); } /** @@ -53,8 +60,8 @@ public PlayableFactory(UrlAudioLoader urlAudioLoader, YouTubeService youTubeServ * necessary. * * @param redirectSpotify if true the matching YouTube video is loaded to play the full track using - * {@link YouTubeService#redirectSpotify(HollowYouTubeVideo)}, else a {@link TrackWrapper} is created to play the - * preview mp3 provided by Spotify + * {@link YouTubeService#redirectSpotify(HollowYouTubeVideo)}, else a {@link TrackWrapper} is created to play the + * preview mp3 provided by Spotify */ public Playable createPlayable(boolean redirectSpotify, Object track) { if (track instanceof Playable) { @@ -76,44 +83,69 @@ public Playable createPlayable(boolean redirectSpotify, Object track) { } } - public List createPlayables(boolean redirectSpotify, Collection tracks) { - return createPlayables(redirectSpotify, tracks, true); + public List createPlayables(boolean redirectSpotify, Collection items) { + return createPlayables(redirectSpotify, items, true); + } + + public List createPlayables(boolean redirectSpotify, Object item) { + return createPlayables(redirectSpotify, item, true); + } + + public List createPlayables(boolean redirectSpotify, Object item, boolean mayInterrupt) { + if (item instanceof Collection) { + return createPlayables(redirectSpotify, (Collection) item, mayInterrupt); + } else { + return createPlayables(redirectSpotify, Lists.newArrayList(item), mayInterrupt); + } } /** * Creates Playables for a Collection of Objects; YouTube videos or Spotify Tracks. * - * @param redirectSpotify f true the matching YouTube video is loaded to play the full track using - * {@link YouTubeService#redirectSpotify(HollowYouTubeVideo)}, else a {@link TrackWrapper} is created to play the - * preview mp3 provided by Spotify - * @param tracks the objects to create a Playable for - * @param mayInterrupt determines whether loading the playables should be interrupted if the same - * {@link GuildTrackLoadingExecutor} loads different playables where mayInterrupt is also true. This is used for - * the play command so that when the user starts playing different tracks the bot can stop wasting resources on loading - * the playables that won't be needed anymore. This should never be true for command like the Queue or AddCommand - * where invoking the command again should not cancel out the last command. - * @return the create Playables + * @param redirectSpotify if true the matching YouTube video is loaded to play the full track using + * {@link YouTubeService#redirectSpotify(HollowYouTubeVideo)}, else a {@link TrackWrapper} is created to play the + * preview mp3 provided by Spotify + * @param items the objects to create a Playable for + * @param mayInterrupt determines whether loading the playables should be interrupted if the same + * {@link GuildTrackLoadingExecutor} loads different playables where mayInterrupt is also true. This is used for + * the play command so that when the user starts playing different tracks the bot can stop wasting resources on loading + * the playables that won't be needed anymore. This should never be true for commands like the Queue or AddCommand + * where invoking the command again should not cancel out the last command. + * @return the created Playables */ - public List createPlayables(boolean redirectSpotify, Collection tracks, boolean mayInterrupt) { + public List createPlayables(boolean redirectSpotify, Collection items, boolean mayInterrupt) { List playables = Lists.newArrayList(); List tracksToRedirect = Lists.newArrayList(); + List youTubePlaylistsToLoad = Lists.newArrayList(); - for (Object track : tracks) { - if (track instanceof Playable) { - playables.add((Playable) track); - } else if (track instanceof Track) { - if (redirectSpotify) { - HollowYouTubeVideo youTubeVideo = new HollowYouTubeVideo(youTubeService, (Track) track); - tracksToRedirect.add(youTubeVideo); - playables.add(youTubeVideo); - } else { - playables.add(new TrackWrapper((Track) track)); + try { + for (Object item : items) { + if (item instanceof Playable) { + playables.add((Playable) item); + } else if (item instanceof Track) { + handleTrack((Track) item, redirectSpotify, tracksToRedirect, playables); + } else if (item instanceof UrlTrack) { + playables.add(((UrlTrack) item).asPlayable()); + } else if (item instanceof YouTubePlaylist) { + YouTubePlaylist youTubePlaylist = ((YouTubePlaylist) item); + playables.addAll(youTubePlaylist.getVideos()); + youTubePlaylistsToLoad.add(youTubePlaylist); + } else if (item instanceof PlaylistSimplified) { + List t = invoker.runWithCredentials(spotifyService.getSpotifyApi(), () -> spotifyService.getPlaylistTracks((PlaylistSimplified) item)); + for (Track track : t) { + handleTrack(track, redirectSpotify, tracksToRedirect, playables); + } + } else if (item instanceof AlbumSimplified) { + List t = invoker.runWithCredentials(spotifyService.getSpotifyApi(), () -> spotifyService.getAlbumTracks((AlbumSimplified) item)); + for (Track track : t) { + handleTrack(track, redirectSpotify, tracksToRedirect, playables); + } + } else if (item != null) { + throw new UnsupportedOperationException("Unsupported playable " + item.getClass()); } - } else if (track instanceof UrlTrack) { - playables.add(((UrlTrack) track).asPlayable()); - } else if (track != null) { - throw new UnsupportedOperationException("Unsupported playable " + track.getClass()); } + } catch (Exception e) { + throw new RuntimeException("Exception while creating Playables", e); } if (!tracksToRedirect.isEmpty()) { @@ -125,12 +157,30 @@ public List createPlayables(boolean redirectSpotify, Collection tra } youTubeService.redirectSpotify(youTubeVideo); } + + for (YouTubePlaylist youTubePlaylist : youTubePlaylistsToLoad) { + if (Thread.currentThread().isInterrupted()) { + youTubePlaylistsToLoad.forEach(YouTubePlaylist::cancelLoading); + } + + youTubeService.populateList(youTubePlaylist); + } }, mayInterrupt); } return playables; } + private void handleTrack(Track track, boolean redirectSpotify, List tracksToRedirect, List playables) { + if (redirectSpotify) { + HollowYouTubeVideo youTubeVideo = new HollowYouTubeVideo(youTubeService, track); + tracksToRedirect.add(youTubeVideo); + playables.add(youTubeVideo); + } else { + playables.add(new TrackWrapper(track)); + } + } + public List createPlayables(YouTubePlaylist youTubePlaylist) { return createPlayables(youTubePlaylist, true); } @@ -140,8 +190,8 @@ public List createPlayables(YouTubePlaylist youTubePlaylist) { * them as Playables * * @param youTubePlaylist the YouTube playlist to load the videos for - * @param mayInterrupt whether or not loading the tracks can be interrupted by a similar action, - * see {@link #createPlayables(boolean, Collection, boolean)} + * @param mayInterrupt whether or not loading the tracks can be interrupted by a similar action, + * see {@link #createPlayables(boolean, Collection, boolean)} * @return the {@link HollowYouTubeVideo}s as Playables */ public List createPlayables(YouTubePlaylist youTubePlaylist, boolean mayInterrupt) { @@ -212,6 +262,16 @@ public Playable createPlayable(String url, SpotifyApi spotifyApi, boolean redire } } + /** + * Create Playables for any URL. + * + * @param url the url that points to a playable track or playlist + * @param spotifyApi the SpotifyApi instance + * @param redirectSpotify if true the loaded Spotify tracks will get directed to YouTube videos + * @param mayInterrupt whether or not loading the tracks can be interrupted by a similar action, + * see {@link #createPlayables(boolean, Collection, boolean)} + * @return the created playables + */ public List createPlayables(String url, SpotifyApi spotifyApi, boolean redirectSpotify, boolean mayInterrupt) { List playables; @@ -291,7 +351,7 @@ private List createPlayablesFromSpotifyUrl(URI uri, SpotifyApi spotify List playlistTracks = spotifyService.getPlaylistTracks(playlistId); return createPlayables(redirectSpotify, playlistTracks, mayInterrupt); } catch (NotFoundException e) { - throw new NoResultsFoundException("No playlist found for id " + playlistId); + throw new NoResultsFoundException(String.format("No Spotify playlist found for id '%s'", playlistId)); } catch (IOException | SpotifyWebApiException e) { throw new RuntimeException("Exception during Spotify request", e); } finally { @@ -309,7 +369,7 @@ private List createPlayablesFromSpotifyUrl(URI uri, SpotifyApi spotify Track track = spotifyApi.getTrack(trackId).build().execute(); return Lists.newArrayList(createPlayable(redirectSpotify, track)); } catch (NotFoundException e) { - throw new NoResultsFoundException("No track found for id " + trackId); + throw new NoResultsFoundException(String.format("No Spotify track found for id '%s'", trackId)); } catch (IOException | SpotifyWebApiException e) { throw new RuntimeException("Exception during Spotify request", e); } finally { @@ -327,7 +387,7 @@ private List createPlayablesFromSpotifyUrl(URI uri, SpotifyApi spotify List albumTracks = spotifyService.getAlbumTracks(albumId); return createPlayables(redirectSpotify, albumTracks, mayInterrupt); } catch (BadRequestException e) { - throw new NoResultsFoundException("No album found for id " + albumId); + throw new NoResultsFoundException(String.format("No album found for id '%s'", albumId)); } catch (IOException | SpotifyWebApiException e) { throw new RuntimeException("Exception during Spotify request", e); } finally { diff --git a/src/main/java/net/robinfriedli/botify/audio/QueueIterator.java b/src/main/java/net/robinfriedli/botify/audio/QueueIterator.java index 673d74de..c2d19892 100644 --- a/src/main/java/net/robinfriedli/botify/audio/QueueIterator.java +++ b/src/main/java/net/robinfriedli/botify/audio/QueueIterator.java @@ -8,14 +8,15 @@ import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; -import net.dv8tion.jda.core.EmbedBuilder; -import net.dv8tion.jda.core.entities.Guild; -import net.dv8tion.jda.core.entities.Message; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; import net.robinfriedli.botify.Botify; import net.robinfriedli.botify.discord.MessageService; import net.robinfriedli.botify.discord.properties.AbstractGuildProperty; import net.robinfriedli.botify.discord.properties.ColorSchemeProperty; import net.robinfriedli.botify.entities.GuildSpecification; +import net.robinfriedli.botify.exceptions.UnavailableResourceException; import net.robinfriedli.botify.util.EmojiConstants; import net.robinfriedli.botify.util.PropertiesLoadingService; import net.robinfriedli.botify.util.StaticSessionProvider; @@ -116,7 +117,7 @@ void playNext() { String playbackUrl; try { playbackUrl = track.getPlaybackUrl(); - } catch (InterruptedException e) { + } catch (UnavailableResourceException e) { ++retryCount; iterateQueue(playback, queue, true); return; @@ -173,7 +174,7 @@ private void iterateQueue(AudioPlayback playback, AudioQueue queue, boolean igno private void sendError(Playable track, Throwable e) { if (retryCount == 0) { EmbedBuilder embedBuilder = new EmbedBuilder(); - embedBuilder.setTitle("Could not load track " + track.getDisplayInterruptible()); + embedBuilder.setTitle("Could not load track " + track.display()); embedBuilder.setDescription(e.getMessage()); embedBuilder.setColor(Color.RED); @@ -188,10 +189,10 @@ private void sendError(Playable track, Throwable e) { private void sendCurrentTrackNotification(Playable currentTrack) { MessageService messageService = new MessageService(); EmbedBuilder embedBuilder = new EmbedBuilder(); - embedBuilder.addField("Now playing", currentTrack.getDisplayInterruptible(), false); + embedBuilder.addField("Now playing", currentTrack.display(), false); if (queue.hasNext()) { - embedBuilder.addField("Next", queue.getNext().getDisplayInterruptible(), false); + embedBuilder.addField("Next", queue.getNext().display(), false); } StringBuilder footerBuilder = new StringBuilder(); diff --git a/src/main/java/net/robinfriedli/botify/audio/UrlPlayable.java b/src/main/java/net/robinfriedli/botify/audio/UrlPlayable.java index b912f306..4934de4b 100644 --- a/src/main/java/net/robinfriedli/botify/audio/UrlPlayable.java +++ b/src/main/java/net/robinfriedli/botify/audio/UrlPlayable.java @@ -5,7 +5,7 @@ import javax.annotation.Nullable; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import net.dv8tion.jda.core.entities.User; +import net.dv8tion.jda.api.entities.User; import net.robinfriedli.botify.entities.Playlist; import net.robinfriedli.botify.entities.PlaylistItem; import net.robinfriedli.botify.entities.UrlTrack; @@ -57,6 +57,11 @@ public String getDisplay(long timeOut, TimeUnit unit) { return getDisplay(); } + @Override + public String getDisplayNow(String alternativeValue) { + return getDisplay(); + } + @Override public long getDurationMs() { return duration; @@ -67,6 +72,11 @@ public long getDurationMs(long timeOut, TimeUnit unit) { return getDurationMs(); } + @Override + public long getDurationNow(long alternativeValue) { + return getDurationMs(); + } + @Override public PlaylistItem export(Playlist playlist, User user, Session session) { return new UrlTrack(this, user, playlist); diff --git a/src/main/java/net/robinfriedli/botify/audio/spotify/SpotifyService.java b/src/main/java/net/robinfriedli/botify/audio/spotify/SpotifyService.java index 6389bca1..b01857ac 100644 --- a/src/main/java/net/robinfriedli/botify/audio/spotify/SpotifyService.java +++ b/src/main/java/net/robinfriedli/botify/audio/spotify/SpotifyService.java @@ -148,6 +148,10 @@ public List getAlbumTracks(String albumId) throws IOException, SpotifyWeb return tracks; } + public List getAlbumTracks(AlbumSimplified albumSimplified) throws IOException, SpotifyWebApiException { + return getAlbumTracks(albumSimplified.getId()); + } + public SpotifyApi getSpotifyApi() { return spotifyApi; } diff --git a/src/main/java/net/robinfriedli/botify/audio/spotify/TrackWrapper.java b/src/main/java/net/robinfriedli/botify/audio/spotify/TrackWrapper.java index f67ef9ee..e0524058 100644 --- a/src/main/java/net/robinfriedli/botify/audio/spotify/TrackWrapper.java +++ b/src/main/java/net/robinfriedli/botify/audio/spotify/TrackWrapper.java @@ -4,7 +4,7 @@ import com.wrapper.spotify.model_objects.specification.ArtistSimplified; import com.wrapper.spotify.model_objects.specification.Track; -import net.dv8tion.jda.core.entities.User; +import net.dv8tion.jda.api.entities.User; import net.robinfriedli.botify.audio.AbstractSoftCachedPlayable; import net.robinfriedli.botify.audio.Playable; import net.robinfriedli.botify.audio.youtube.HollowYouTubeVideo; @@ -50,6 +50,11 @@ public String getDisplay(long timeOut, TimeUnit unit) { return getDisplay(); } + @Override + public String getDisplayNow(String alternativeValue) { + return getDisplay(); + } + @Override public long getDurationMs() { return track.getDurationMs(); @@ -60,6 +65,11 @@ public long getDurationMs(long timeOut, TimeUnit unit) { return getDurationMs(); } + @Override + public long getDurationNow(long alternativeValue) { + return getDurationMs(); + } + @Override public PlaylistItem export(Playlist playlist, User user, Session session) { return new Song(track, user, playlist, session); diff --git a/src/main/java/net/robinfriedli/botify/audio/youtube/HollowYouTubeVideo.java b/src/main/java/net/robinfriedli/botify/audio/youtube/HollowYouTubeVideo.java index 75b56ba6..8b4dc46f 100644 --- a/src/main/java/net/robinfriedli/botify/audio/youtube/HollowYouTubeVideo.java +++ b/src/main/java/net/robinfriedli/botify/audio/youtube/HollowYouTubeVideo.java @@ -11,6 +11,7 @@ import com.wrapper.spotify.model_objects.specification.Track; import net.robinfriedli.botify.audio.AbstractSoftCachedPlayable; +import net.robinfriedli.botify.exceptions.UnavailableResourceException; /** * YouTube video when the data has not been loaded yet. This is used for YouTube playlist elements or Spotify tracks that @@ -39,7 +40,7 @@ public HollowYouTubeVideo(YouTubeService youTubeService, @Nullable Track redirec } @Override - public String getTitle() throws InterruptedException { + public String getTitle() throws UnavailableResourceException { return getCompleted(title); } @@ -48,17 +49,22 @@ public void setTitle(String title) { } @Override - public String getTitle(long timeOut, TimeUnit unit) throws InterruptedException, TimeoutException { + public String getTitle(long timeOut, TimeUnit unit) throws UnavailableResourceException, TimeoutException { return getWithTimeout(title, timeOut, unit); } @Override - public String getVideoId() throws InterruptedException { + public String getDisplayNow(String alternativeValue) throws UnavailableResourceException { + return getNow(title, alternativeValue); + } + + @Override + public String getVideoId() throws UnavailableResourceException { return getCompleted(id); } @Override - public String getId() throws InterruptedException { + public String getId() throws UnavailableResourceException { return redirectedSpotifyTrack != null ? redirectedSpotifyTrack.getId() : getVideoId(); } @@ -67,12 +73,7 @@ public void setId(String id) { } @Override - public String getVideoId(long timeOut, TimeUnit unit) throws InterruptedException, TimeoutException { - return getWithTimeout(id, timeOut, unit); - } - - @Override - public long getDuration() throws InterruptedException { + public long getDuration() throws UnavailableResourceException { return getCompleted(duration); } @@ -81,10 +82,15 @@ public void setDuration(long duration) { } @Override - public long getDuration(long timeOut, TimeUnit unit) throws InterruptedException, TimeoutException { + public long getDuration(long timeOut, TimeUnit unit) throws UnavailableResourceException, TimeoutException { return getWithTimeout(duration, timeOut, unit); } + @Override + public long getDurationNow(long alternativeValue) throws UnavailableResourceException { + return getNow(duration, alternativeValue); + } + @Nullable @Override public Track getRedirectedSpotifyTrack() { @@ -120,7 +126,7 @@ public String getSource() { return redirectedSpotifyTrack != null ? "Spotify" : "YouTube"; } - private E getCompleted(CompletableFuture future) throws InterruptedException { + private E getCompleted(CompletableFuture future) throws UnavailableResourceException { try { if (!future.isDone() && redirectedSpotifyTrack != null) { youTubeService.redirectSpotify(this); @@ -132,17 +138,28 @@ private E getCompleted(CompletableFuture future) throws InterruptedExcept } catch (TimeoutException e) { throw new RuntimeException("Video loading timed out", e); } catch (CancellationException e) { - throw new InterruptedException(); + throw new UnavailableResourceException(); } } - private E getWithTimeout(CompletableFuture future, long time, TimeUnit unit) throws InterruptedException, TimeoutException { + private E getWithTimeout(CompletableFuture future, long time, TimeUnit unit) throws UnavailableResourceException, TimeoutException { try { return future.get(time, unit); - } catch (ExecutionException e) { + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } catch (CancellationException e) { + throw new UnavailableResourceException(); + } + } + + private E getNow(CompletableFuture future, E alternativeValue) throws UnavailableResourceException { + try { + return future.getNow(alternativeValue); + } catch (CompletionException e) { throw new RuntimeException(e); } catch (CancellationException e) { - throw new InterruptedException(); + throw new UnavailableResourceException(); } } + } diff --git a/src/main/java/net/robinfriedli/botify/audio/youtube/YouTubeService.java b/src/main/java/net/robinfriedli/botify/audio/youtube/YouTubeService.java index d108763f..a96989cd 100644 --- a/src/main/java/net/robinfriedli/botify/audio/youtube/YouTubeService.java +++ b/src/main/java/net/robinfriedli/botify/audio/youtube/YouTubeService.java @@ -27,6 +27,7 @@ import net.robinfriedli.botify.command.commands.PlayCommand; import net.robinfriedli.botify.command.commands.QueueCommand; import net.robinfriedli.botify.exceptions.NoResultsFoundException; +import net.robinfriedli.botify.exceptions.UnavailableResourceException; import net.robinfriedli.stringlist.StringList; import net.robinfriedli.stringlist.StringListImpl; @@ -196,7 +197,7 @@ private List searchVideos(long limit, String searchTerm) throws IO List items = search.execute().getItems(); if (items.isEmpty()) { - throw new NoResultsFoundException("No YouTube video found for " + searchTerm); + throw new NoResultsFoundException(String.format("No YouTube video found for '%s'", searchTerm)); } return items; @@ -215,7 +216,8 @@ private List