**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.
This commit is contained in:
anthorob committed 2026-10-31 16:13:04 -04:00
1 parent 764adb6cbc
commit d97c4c6da2
16 files changed
+672 -2

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by fea
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. | | `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. | | `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. | | `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. | | `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. | | `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). | | `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
+1 -1
View File
@@ -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 All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`, only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`, `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). [commands.md](commands.md#command-catalog).
+1
View File
@@ -70,6 +70,7 @@ music channel when a context is attached.
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). | | `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
| `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. | | `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}/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`. | | `PUT /music/{guildId}/seek` | Seek to `positionMs`. |
| `GET /music/{guildId}/vote-skip` | Vote-skip status. | | `GET /music/{guildId}/vote-skip` | Vote-skip status. |
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. | | `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
@@ -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();
}
}
@@ -17,6 +17,8 @@ import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiParam; import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiRequestBody; import io.javalin.openapi.OpenApiRequestBody;
import io.javalin.openapi.OpenApiResponse; 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.CommandResultResponse;
import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse; import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse;
import me.bobert.netneutrality.http.server.dto.DjStatusResponse; 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.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx));
routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx)); routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx));
routes.put("/music/{guildId}/volume", ctx -> volume(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.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx));
routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(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)); routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx));
@@ -284,6 +288,46 @@ public final class MusicRoutes {
return volume; 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( @OpenApi(
path = "/music/{guildId}/seek", path = "/music/{guildId}/seek",
methods = HttpMethod.PUT, methods = HttpMethod.PUT,
@@ -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
) {
}
@@ -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
) {
}
@@ -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<Track> 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();
}
}
@@ -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<String> radioIdentifier(Track seed);
}
@@ -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<AutoplayProvider> providers;
public AutoplayService() {
this(List.of(new YoutubeAutoplayProvider()));
}
public AutoplayService(List<AutoplayProvider> 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<String> 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();
}
}
}
@@ -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<Track> 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<TrackInfo> 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<TrackInfo> all = new ArrayList<>(queue);
List<Map<String, Object>> 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) { public void queue(Track track, Member member) {
TrackInfo info = new TrackInfo(track, member); TrackInfo info = new TrackInfo(track, member);
@@ -18,6 +18,8 @@ import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Sink for the track lifecycle events emitted by the Lavalink node. * Sink for the track lifecycle events emitted by the Lavalink node.
* *
@@ -35,8 +37,16 @@ public class TrackScheduler {
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler; private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
private static final long POSITION_THROTTLE_MS = 5000; private static final long POSITION_THROTTLE_MS = 5000;
private volatile long lastPositionSent = Long.MIN_VALUE; 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) { public TrackScheduler(MusicManager manager) {
this(manager, new AutoplayService());
}
public TrackScheduler(MusicManager manager, AutoplayService autoplayService) {
this.autoplayService = autoplayService;
this.manager = manager; this.manager = manager;
this.callback = manager; this.callback = manager;
this.guild = manager.getGuild(); this.guild = manager.getGuild();
@@ -76,6 +86,36 @@ public class TrackScheduler {
this.currentTrack = track; 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() { public Member getDj() {
Context ctx = context; Context ctx = context;
if (ctx != null) { if (ctx != null) {
@@ -106,6 +146,7 @@ public class TrackScheduler {
currentTrack = track; currentTrack = track;
stuckTrackRecoveryHandler.reset(); stuckTrackRecoveryHandler.reset();
publish(MusicEvent.Type.TRACK_START, track, null); publish(MusicEvent.Type.TRACK_START, track, null);
maybeAutoplay();
} }
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) { public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
@@ -168,7 +209,7 @@ public class TrackScheduler {
java.util.Map<String, Object> payload = new java.util.HashMap<>(); java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track)); payload.put("track", trackPayload(track));
payload.put("message", exception.getMessage()); 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)); manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
} }
@@ -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<String> radioIdentifier(Track seed) {
return videoId(seed).map(id -> "https://www.youtube.com/watch?v=" + id + "&list=RD" + id);
}
private static Optional<String> 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();
}
}
@@ -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<List<Track>> captor = org.mockito.ArgumentCaptor.forClass(List.class);
org.mockito.ArgumentCaptor<PlaylistInfo> 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();
}
}
@@ -146,6 +146,64 @@ public class TrackSchedulerTest {
assertEquals(0, events.size()); 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 @Test
public void getDjReturnsNullWhenNoContext() { public void getDjReturnsNullWhenNoContext() {
assertNull(scheduler.getDj()); assertNull(scheduler.getDj());
@@ -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));
}
}