From d97c4c6da26a0c813d31d3e4e35281123dc79e65 Mon Sep 17 00:00:00 2001 From: anthorob Date: Sat, 31 Oct 2026 16:13:04 -0400 Subject: [PATCH] **Add autoplay support for seamless music playback** Introduced autoplay functionality to keep music playing when the queue ends. Added new `/autoplay` API endpoints, command handling, and service implementations. Enhanced `TrackScheduler`, queue operations, and testing to support autoplay behavior. Integrated YouTube radio mix as the initial provider. --- docs/commands.md | 1 + docs/music.md | 2 +- docs/web-api.md | 1 + .../command/music/AutoplayCommand.java | 76 ++++++++++++++ .../http/server/MusicRoutes.java | 44 +++++++++ .../http/server/dto/AutoplayRequest.java | 15 +++ .../http/server/dto/AutoplayResponse.java | 15 +++ .../music/AutoplayLoadHandler.java | 84 ++++++++++++++++ .../netneutrality/music/AutoplayProvider.java | 30 ++++++ .../netneutrality/music/AutoplayService.java | 73 ++++++++++++++ .../netneutrality/music/QueueManager.java | 36 +++++++ .../netneutrality/music/TrackScheduler.java | 43 +++++++- .../music/YoutubeAutoplayProvider.java | 45 +++++++++ .../music/AutoplayLoadHandlerTest.java | 99 +++++++++++++++++++ .../music/TrackSchedulerTest.java | 58 +++++++++++ .../music/YoutubeAutoplayProviderTest.java | 52 ++++++++++ 16 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/command/music/AutoplayCommand.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayRequest.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayResponse.java create mode 100644 src/main/java/me/bobert/netneutrality/music/AutoplayLoadHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/music/AutoplayProvider.java create mode 100644 src/main/java/me/bobert/netneutrality/music/AutoplayService.java create mode 100644 src/main/java/me/bobert/netneutrality/music/YoutubeAutoplayProvider.java create mode 100644 src/test/java/me/bobert/netneutrality/music/AutoplayLoadHandlerTest.java create mode 100644 src/test/java/me/bobert/netneutrality/music/YoutubeAutoplayProviderTest.java diff --git a/docs/commands.md b/docs/commands.md index eee03f4..4e6dcc4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by fea | `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. | | `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. | | `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. | +| `AutoplayCommand` | `music` | Slash | Toggle autoplay: queues the YouTube radio mix of the last track so music keeps playing. | | `BitconnectCommand` | `music` | Slash | Novelty music command. | | `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. | | `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). | diff --git a/docs/music.md b/docs/music.md index ba2c258..ce36511 100644 --- a/docs/music.md +++ b/docs/music.md @@ -166,5 +166,5 @@ scope of this migration and is tracked as a follow-up. All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`, `QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`, -`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See +`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `AutoplayCommand`, `BitconnectCommand`. See [commands.md](commands.md#command-catalog). diff --git a/docs/web-api.md b/docs/web-api.md index 431e4ed..d7010ca 100644 --- a/docs/web-api.md +++ b/docs/web-api.md @@ -70,6 +70,7 @@ music channel when a context is attached. | `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). | | `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. | | `GET` / `PUT /music/{guildId}/volume` | Read / set volume (0–100). | +| `GET` / `PUT /music/{guildId}/autoplay` | Read / set autoplay (`{"enabled": true}`). In-memory, resets when the player is destroyed. | | `PUT /music/{guildId}/seek` | Seek to `positionMs`. | | `GET /music/{guildId}/vote-skip` | Vote-skip status. | | `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. | diff --git a/src/main/java/me/bobert/netneutrality/command/music/AutoplayCommand.java b/src/main/java/me/bobert/netneutrality/command/music/AutoplayCommand.java new file mode 100644 index 0000000..03bb640 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/command/music/AutoplayCommand.java @@ -0,0 +1,76 @@ +package me.bobert.netneutrality.command.music; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.CommandExecutionResult; +import me.bobert.netneutrality.core.command.CommandOptionsBuilder; +import me.bobert.netneutrality.core.command.impl.MusicCommand; +import me.bobert.netneutrality.core.command.options.CommandOption; +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.music.MusicManager; +import me.bobert.netneutrality.music.MusicService; +import me.bobert.netneutrality.music.TrackScheduler; +import me.bobert.netneutrality.utils.MusicUtils; +import me.bobert.netneutrality.utils.UserFunction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +public class AutoplayCommand extends MusicCommand { + + private final MusicService musicService; + + public AutoplayCommand() { + this(null, null); + } + + public AutoplayCommand(MusicService musicService) { + this(musicService, null); + } + + public AutoplayCommand(MusicService musicService, GuildServiceProvider guildServices) { + super(guildServices); + this.musicService = musicService; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public String getCommandName() { + return "autoplay"; + } + + @Override + public String getDescription() { + return "Toggles autoplay, which keeps the music going with similar tracks when the queue ends"; + } + + @Override + public void registerCommandOptions(CommandOptionsBuilder builder) { + builder.addOptions(new OptionData(OptionType.BOOLEAN, "enabled", "Turn autoplay on or off; omit to toggle", false)); + } + + @Override + public CommandExecutionResult execute(CommandContext context) { + MusicManager manager = musicService.get(context.getGuild()); + Boolean requested = context.getOption("enabled", CommandOption::getAsBoolean); + + if (MusicUtils.checkIfConnected(context, manager)) { + if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) { + TrackScheduler scheduler = manager.getScheduler(); + boolean enabled = requested != null ? requested : !scheduler.isAutoplayEnabled(); + scheduler.setAutoplayEnabled(enabled); + return CommandExecutionResult.success("Autoplay is now " + (enabled ? "enabled" : "disabled")); + } + } + + return CommandExecutionResult.none(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java index 2bb05be..054e626 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java @@ -17,6 +17,8 @@ import io.javalin.openapi.OpenApiContent; import io.javalin.openapi.OpenApiParam; import io.javalin.openapi.OpenApiRequestBody; import io.javalin.openapi.OpenApiResponse; +import me.bobert.netneutrality.http.server.dto.AutoplayRequest; +import me.bobert.netneutrality.http.server.dto.AutoplayResponse; import me.bobert.netneutrality.http.server.dto.CommandResultResponse; import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse; import me.bobert.netneutrality.http.server.dto.DjStatusResponse; @@ -79,6 +81,8 @@ public final class MusicRoutes { routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx)); routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx)); routes.put("/music/{guildId}/volume", ctx -> volume(shards, musicService, ctx)); + routes.get("/music/{guildId}/autoplay", ctx -> getAutoplay(shards, musicService, ctx)); + routes.put("/music/{guildId}/autoplay", ctx -> autoplay(shards, musicService, ctx)); routes.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx)); routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(shards, musicService, ctx)); routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx)); @@ -284,6 +288,46 @@ public final class MusicRoutes { return volume; } + @OpenApi( + path = "/music/{guildId}/autoplay", + methods = HttpMethod.GET, + summary = "Get whether autoplay is enabled for a guild", + tags = {"Music"}, + pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), + responses = { + @OpenApiResponse(status = "200", content = @OpenApiContent(from = AutoplayResponse.class)), + @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) + } + ) + private static void getAutoplay(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); + ctx.json(new AutoplayResponse(manager.getScheduler().isAutoplayEnabled())); + } + + @OpenApi( + path = "/music/{guildId}/autoplay", + methods = HttpMethod.PUT, + summary = "Enable or disable autoplay for a guild", + tags = {"Music"}, + pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), + requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = AutoplayRequest.class)), + responses = { + @OpenApiResponse(status = "200", content = @OpenApiContent(from = AutoplayResponse.class)), + @OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)), + @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) + } + ) + private static void autoplay(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); + Object enabled = ctx.bodyAsClass(Map.class).get("enabled"); + if (!(enabled instanceof Boolean value)) { + throw new BadRequestResponse("enabled must be a boolean"); + } + + manager.getScheduler().setAutoplayEnabled(value); + ctx.json(new AutoplayResponse(value)); + } + @OpenApi( path = "/music/{guildId}/seek", methods = HttpMethod.PUT, diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayRequest.java new file mode 100644 index 0000000..9c6dd34 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayRequest.java @@ -0,0 +1,15 @@ +package me.bobert.netneutrality.http.server.dto; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * OpenAPI schema for the {@code /music/{guildId}/autoplay} request body. + */ +public record AutoplayRequest( + Boolean enabled +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayResponse.java new file mode 100644 index 0000000..826747b --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/AutoplayResponse.java @@ -0,0 +1,15 @@ +package me.bobert.netneutrality.http.server.dto; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * OpenAPI schema for the {@code /music/{guildId}/autoplay} response. + */ +public record AutoplayResponse( + boolean enabled +) { +} diff --git a/src/main/java/me/bobert/netneutrality/music/AutoplayLoadHandler.java b/src/main/java/me/bobert/netneutrality/music/AutoplayLoadHandler.java new file mode 100644 index 0000000..861d526 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/AutoplayLoadHandler.java @@ -0,0 +1,84 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler; +import dev.arbjerg.lavalink.client.player.LoadFailed; +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.SearchResult; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.client.player.TrackLoaded; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Objects; + +/** + * Queues the tracks of an autoplay mix in the background. Unlike {@link TrackLoadingHandler} + * it has no user to reply to: failures and empty results are only logged. + */ +public class AutoplayLoadHandler extends AbstractAudioLoadResultHandler { + private static final Logger logger = LogManager.getLogger(AutoplayLoadHandler.class); + + private final TrackScheduler scheduler; + private final Track seed; + + public AutoplayLoadHandler(TrackScheduler scheduler, Track seed) { + this.scheduler = scheduler; + this.seed = seed; + } + + private String guildId() { + return scheduler.getGuild().getId(); + } + + @Override + public void onPlaylistLoaded(PlaylistLoaded result) { + try { + String seedId = seed.getInfo().getIdentifier(); + List tracks = result.getTracks().stream() + .filter(track -> !Objects.equals(track.getInfo().getIdentifier(), seedId)) + .toList(); + + if (tracks.isEmpty()) { + logger.debug("Guild {}: autoplay mix is empty", guildId()); + return; + } + + TrackInfo seedInfo = scheduler.getQueueManager().peekHeadOfQueue(); + PlaylistInfo playlist = new PlaylistInfo(null, "Autoplay: " + seed.getInfo().getTitle(), tracks.size()); + scheduler.getQueueManager().queueAutoplay( + tracks, seedInfo == null ? null : seedInfo.getRequester(), playlist); + } finally { + scheduler.endAutoplayLoad(); + } + } + + @Override + public void ontrackLoaded(TrackLoaded result) { + logger.debug("Guild {}: autoplay resolved a single track, ignoring", guildId()); + scheduler.endAutoplayLoad(); + } + + @Override + public void onSearchResultLoaded(SearchResult result) { + logger.debug("Guild {}: autoplay resolved search results, ignoring", guildId()); + scheduler.endAutoplayLoad(); + } + + @Override + public void noMatches() { + logger.debug("Guild {}: autoplay found no matches", guildId()); + scheduler.endAutoplayLoad(); + } + + @Override + public void loadFailed(LoadFailed result) { + logger.debug("Guild {}: autoplay load failed: {}", guildId(), result.getException().getMessage()); + scheduler.endAutoplayLoad(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/music/AutoplayProvider.java b/src/main/java/me/bobert/netneutrality/music/AutoplayProvider.java new file mode 100644 index 0000000..14e6328 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/AutoplayProvider.java @@ -0,0 +1,30 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.player.Track; + +import java.util.Optional; + +/** + * Source-specific strategy that turns the last track of the queue into a Lavalink identifier + * whose tracks are queued next (e.g. a radio mix). Register new sources in + * {@link AutoplayService}. + */ +public interface AutoplayProvider { + + /** + * @param seed the track autoplay is seeded from + * @return whether this provider can build recommendations for the track's source + */ + boolean supports(Track seed); + + /** + * @param seed the track autoplay is seeded from + * @return the identifier to load through Lavalink, or empty when none can be built + */ + Optional radioIdentifier(Track seed); +} diff --git a/src/main/java/me/bobert/netneutrality/music/AutoplayService.java b/src/main/java/me/bobert/netneutrality/music/AutoplayService.java new file mode 100644 index 0000000..67af554 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/AutoplayService.java @@ -0,0 +1,73 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.Link; +import dev.arbjerg.lavalink.client.player.Track; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Optional; + +/** + * Picks the {@link AutoplayProvider} matching a seed track and loads its recommendations + * in the background. At most one load per scheduler is in flight at a time. + */ +public class AutoplayService { + private static final Logger logger = LogManager.getLogger(AutoplayService.class); + + private final List providers; + + public AutoplayService() { + this(List.of(new YoutubeAutoplayProvider())); + } + + public AutoplayService(List providers) { + this.providers = List.copyOf(providers); + } + + /** + * Starts loading recommendations for the seed track; does nothing when a load is already + * running, no provider supports the track or the node is unavailable. + */ + public void loadFor(TrackScheduler scheduler, Track seed) { + Optional identifier = providers.stream() + .filter(provider -> provider.supports(seed)) + .findFirst() + .flatMap(provider -> provider.radioIdentifier(seed)); + + String guildId = scheduler.getGuild().getId(); + if (identifier.isEmpty()) { + logger.debug("Guild {}: no autoplay provider for track {}", guildId, seed.getInfo().getIdentifier()); + return; + } + + if (!scheduler.tryBeginAutoplayLoad()) { + return; + } + + try { + Link link = scheduler.getMusicManager().getLink(); + if (link == null) { + logger.debug("Guild {}: autoplay skipped, node unavailable", guildId); + scheduler.endAutoplayLoad(); + return; + } + + link.loadItem(identifier.get()).subscribe( + new AutoplayLoadHandler(scheduler, seed), + error -> { + logger.debug("Guild {}: autoplay load failed: {}", guildId, error.getMessage()); + scheduler.endAutoplayLoad(); + } + ); + } catch (RuntimeException e) { + logger.debug("Guild {}: autoplay load could not start: {}", guildId, e.getMessage()); + scheduler.endAutoplayLoad(); + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/music/QueueManager.java b/src/main/java/me/bobert/netneutrality/music/QueueManager.java index 4ad3e5f..3232685 100644 --- a/src/main/java/me/bobert/netneutrality/music/QueueManager.java +++ b/src/main/java/me/bobert/netneutrality/music/QueueManager.java @@ -217,6 +217,42 @@ public class QueueManager { } } + /** + * Appends autoplay tracks behind the playing one. The result of a background load is only + * applied when autoplay is still enabled and the playing track is still the last one; + * otherwise it is discarded. Never starts playback. + */ + public void queueAutoplay(List tracks, Member member, PlaylistInfo playlist) { + synchronized (queue) { + if (!scheduler.isAutoplayEnabled() || queue.size() > 1 || scheduler.getCurrentTrack() == null) { + logger.debug("Guild {}: discarding autoplay result", guild.getId()); + return; + } + + List added = new ArrayList<>(); + for (Track track : tracks) { + TrackInfo info = new TrackInfo(track, member, playlist); + if (queue.contains(info)) { + continue; + } + queue.add(info); + added.add(info); + } + + if (added.isEmpty()) { + return; + } + + PlaylistInfo actual = new PlaylistInfo(playlist.identifier(), playlist.name(), added.size()); + List all = new ArrayList<>(queue); + List> items = new ArrayList<>(); + for (TrackInfo info : added) { + items.add(item(all.indexOf(info), info)); + } + notifyQueueUpdated("playlist_added", items, actual); + } + } + public void queue(Track track, Member member) { TrackInfo info = new TrackInfo(track, member); diff --git a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java index 9938816..a8fbb3c 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java @@ -18,6 +18,8 @@ import net.dv8tion.jda.api.entities.Member; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.concurrent.atomic.AtomicBoolean; + /** * Sink for the track lifecycle events emitted by the Lavalink node. * @@ -35,8 +37,16 @@ public class TrackScheduler { private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler; private static final long POSITION_THROTTLE_MS = 5000; private volatile long lastPositionSent = Long.MIN_VALUE; + private final AutoplayService autoplayService; + private volatile boolean autoplayEnabled; + private final AtomicBoolean autoplayLoading = new AtomicBoolean(); public TrackScheduler(MusicManager manager) { + this(manager, new AutoplayService()); + } + + public TrackScheduler(MusicManager manager, AutoplayService autoplayService) { + this.autoplayService = autoplayService; this.manager = manager; this.callback = manager; this.guild = manager.getGuild(); @@ -76,6 +86,36 @@ public class TrackScheduler { this.currentTrack = track; } + public boolean isAutoplayEnabled() { + return autoplayEnabled; + } + + /** + * Enables or disables autoplay. Enabling it while the last track of the queue is already + * playing loads its recommendations immediately. + */ + public void setAutoplayEnabled(boolean enabled) { + this.autoplayEnabled = enabled; + if (enabled) { + maybeAutoplay(); + } + } + + boolean tryBeginAutoplayLoad() { + return autoplayLoading.compareAndSet(false, true); + } + + void endAutoplayLoad() { + autoplayLoading.set(false); + } + + private void maybeAutoplay() { + Track track = currentTrack; + if (autoplayEnabled && track != null && queueManager.getQueueSize() <= 1) { + autoplayService.loadFor(this, track); + } + } + public Member getDj() { Context ctx = context; if (ctx != null) { @@ -106,6 +146,7 @@ public class TrackScheduler { currentTrack = track; stuckTrackRecoveryHandler.reset(); publish(MusicEvent.Type.TRACK_START, track, null); + maybeAutoplay(); } public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) { @@ -168,7 +209,7 @@ public class TrackScheduler { java.util.Map payload = new java.util.HashMap<>(); payload.put("track", trackPayload(track)); payload.put("message", exception.getMessage()); - payload.put("severity", exception.getSeverity() == null ? null : exception.getSeverity().name()); + payload.put("severity", exception.getSeverity().name()); manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload)); } diff --git a/src/main/java/me/bobert/netneutrality/music/YoutubeAutoplayProvider.java b/src/main/java/me/bobert/netneutrality/music/YoutubeAutoplayProvider.java new file mode 100644 index 0000000..0d797bd --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/YoutubeAutoplayProvider.java @@ -0,0 +1,45 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.player.Track; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Autoplay through the YouTube radio mix of the seed track. + */ +public class YoutubeAutoplayProvider implements AutoplayProvider { + private static final Pattern VIDEO_ID = Pattern.compile("(?:[?&]v=|youtu\\.be/)([-_a-zA-Z0-9]{11})"); + + @Override + public boolean supports(Track seed) { + return videoId(seed).isPresent(); + } + + @Override + public Optional radioIdentifier(Track seed) { + return videoId(seed).map(id -> "https://www.youtube.com/watch?v=" + id + "&list=RD" + id); + } + + private static Optional videoId(Track seed) { + if ("youtube".equalsIgnoreCase(seed.getInfo().getSourceName())) { + String identifier = seed.getInfo().getIdentifier(); + return identifier == null || identifier.isBlank() ? Optional.empty() : Optional.of(identifier); + } + + String uri = seed.getInfo().getUri(); + if (uri != null && SourceFinder.getSource(uri) == SourceFinder.AudioSourceType.Youtube) { + Matcher matcher = VIDEO_ID.matcher(uri); + if (matcher.find()) { + return Optional.of(matcher.group(1)); + } + } + return Optional.empty(); + } +} diff --git a/src/test/java/me/bobert/netneutrality/music/AutoplayLoadHandlerTest.java b/src/test/java/me/bobert/netneutrality/music/AutoplayLoadHandlerTest.java new file mode 100644 index 0000000..4f1c7e5 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/music/AutoplayLoadHandlerTest.java @@ -0,0 +1,99 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.Track; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AutoplayLoadHandlerTest { + + private TrackScheduler scheduler; + private QueueManager queueManager; + private Track seed; + private Member requester; + private AutoplayLoadHandler handler; + + @BeforeEach + void setUp() { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild1"); + scheduler = mock(TrackScheduler.class); + queueManager = mock(QueueManager.class); + when(scheduler.getGuild()).thenReturn(guild); + when(scheduler.getQueueManager()).thenReturn(queueManager); + + seed = QueueManagerTest.mockTrack("seed"); + requester = mock(Member.class); + when(queueManager.peekHeadOfQueue()).thenReturn(new TrackInfo(seed, requester)); + + handler = new AutoplayLoadHandler(scheduler, seed); + } + + private static PlaylistLoaded playlist(Track... tracks) { + PlaylistLoaded result = mock(PlaylistLoaded.class); + when(result.getTracks()).thenReturn(List.of(tracks)); + return result; + } + + @SuppressWarnings("unchecked") + @Test + public void queuesMixWithoutSeedAndClearsLoadingFlag() { + Track other = QueueManagerTest.mockTrack("other"); + + handler.onPlaylistLoaded(playlist(seed, other)); + + org.mockito.ArgumentCaptor> captor = org.mockito.ArgumentCaptor.forClass(List.class); + org.mockito.ArgumentCaptor info = org.mockito.ArgumentCaptor.forClass(PlaylistInfo.class); + verify(queueManager).queueAutoplay(captor.capture(), same(requester), info.capture()); + assertEquals(1, captor.getValue().size()); + assertSame(other, captor.getValue().get(0)); + assertEquals("Autoplay: Title seed", info.getValue().name()); + verify(scheduler).endAutoplayLoad(); + } + + @Test + public void emptyMixQueuesNothing() { + handler.onPlaylistLoaded(playlist(seed)); + + verify(queueManager, never()).queueAutoplay(any(), any(), any()); + verify(scheduler).endAutoplayLoad(); + } + + @Test + public void noMatchesClearsLoadingFlag() { + handler.noMatches(); + + verify(queueManager, never()).queueAutoplay(any(), any(), any()); + verify(scheduler).endAutoplayLoad(); + } + + @Test + public void loadFailedClearsLoadingFlag() { + dev.arbjerg.lavalink.client.player.LoadFailed failed = mock(dev.arbjerg.lavalink.client.player.LoadFailed.class); + dev.arbjerg.lavalink.client.player.TrackException exception = mock(dev.arbjerg.lavalink.client.player.TrackException.class); + when(failed.getException()).thenReturn(exception); + + handler.loadFailed(failed); + + verify(scheduler).endAutoplayLoad(); + } +} diff --git a/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java b/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java index 3b88043..de2b86d 100644 --- a/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java +++ b/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java @@ -146,6 +146,64 @@ public class TrackSchedulerTest { assertEquals(0, events.size()); } + @Test + public void onTrackStartLoadsAutoplayWhenEnabledAndLastTrack() { + AutoplayService service = mock(AutoplayService.class); + TrackScheduler autoplay = new TrackScheduler(musicManager, service); + Track track = QueueManagerTest.mockTrack("t1"); + autoplay.setAutoplayEnabled(true); + + autoplay.onTrackStart(track); + + verify(service).loadFor(autoplay, track); + } + + @Test + public void onTrackStartDoesNotLoadAutoplayWhenDisabled() { + AutoplayService service = mock(AutoplayService.class); + TrackScheduler autoplay = new TrackScheduler(musicManager, service); + + autoplay.onTrackStart(QueueManagerTest.mockTrack("t1")); + + verify(service, never()).loadFor(any(), any()); + } + + @Test + public void onTrackStartDoesNotLoadAutoplayWhenMoreTracksQueued() { + AutoplayService service = mock(AutoplayService.class); + TrackScheduler autoplay = new TrackScheduler(musicManager, service); + net.dv8tion.jda.api.entities.Member member = mock(net.dv8tion.jda.api.entities.Member.class); + Track t1 = QueueManagerTest.mockTrack("t1"); + autoplay.getQueueManager().queue(t1, member); + autoplay.getQueueManager().queue(QueueManagerTest.mockTrack("t2"), member); + autoplay.setAutoplayEnabled(true); + clearInvocations(service); + + autoplay.onTrackStart(t1); + + verify(service, never()).loadFor(any(), any()); + } + + @Test + public void enablingAutoplayWhileLastTrackPlayingTriggersLoad() { + AutoplayService service = mock(AutoplayService.class); + TrackScheduler autoplay = new TrackScheduler(musicManager, service); + Track track = QueueManagerTest.mockTrack("t1"); + autoplay.onTrackStart(track); + + autoplay.setAutoplayEnabled(true); + + verify(service).loadFor(autoplay, track); + } + + @Test + public void autoplayLoadGuardAllowsOneLoadAtATime() { + assertEquals(true, scheduler.tryBeginAutoplayLoad()); + assertEquals(false, scheduler.tryBeginAutoplayLoad()); + scheduler.endAutoplayLoad(); + assertEquals(true, scheduler.tryBeginAutoplayLoad()); + } + @Test public void getDjReturnsNullWhenNoContext() { assertNull(scheduler.getDj()); diff --git a/src/test/java/me/bobert/netneutrality/music/YoutubeAutoplayProviderTest.java b/src/test/java/me/bobert/netneutrality/music/YoutubeAutoplayProviderTest.java new file mode 100644 index 0000000..2d177bc --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/music/YoutubeAutoplayProviderTest.java @@ -0,0 +1,52 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.player.Track; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +public class YoutubeAutoplayProviderTest { + + private final YoutubeAutoplayProvider provider = new YoutubeAutoplayProvider(); + + private static Track track(String id, String source, String uri) { + Track track = QueueManagerTest.mockTrack(id); + when(track.getInfo().getSourceName()).thenReturn(source); + when(track.getInfo().getUri()).thenReturn(uri); + return track; + } + + @Test + public void youtubeTrackBuildsRadioMixUrl() { + Track track = track("abc", "youtube", "https://www.youtube.com/watch?v=abc"); + + assertTrue(provider.supports(track)); + assertEquals(Optional.of("https://www.youtube.com/watch?v=abc&list=RDabc"), provider.radioIdentifier(track)); + } + + @Test + public void soundcloudTrackIsNotSupported() { + Track track = track("123", "soundcloud", "https://soundcloud.com/artist/song"); + + assertFalse(provider.supports(track)); + assertEquals(Optional.empty(), provider.radioIdentifier(track)); + } + + @Test + public void fallsBackToVideoIdFromUri() { + Track track = track("ignored", "http", "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5"); + + assertEquals(Optional.of("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ"), + provider.radioIdentifier(track)); + } +}