Add REST-driven music playback and pause/resume support

Introduced `/music/{guildId}/play` and `/music/{guildId}/play/select/{searchId}` POST routes for initiating and selecting music playback via the REST API. Added `/music/{guildId}/pause` and `/music/{guildId}/resume` POST routes to control playback state. Updated the music system with `RestTrackLoadingHandler`, `PlayOutcome`, `PendingSearchResults`, and DTOs for OpenAPI integration. Enhanced `MusicManager` to support REST-driven play requests with robust error handling and improved track loading logic.
This commit is contained in:
anthorob committed 2026-10-02 21:17:36 -04:00
1 parent 6d62a38b0b
commit d53cbbecd4
9 files changed
+574 -7

No files matched your search

@@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.player.Track;
import io.javalin.config.RoutesConfig; import io.javalin.config.RoutesConfig;
import io.javalin.http.BadRequestResponse; import io.javalin.http.BadRequestResponse;
import io.javalin.http.Context; import io.javalin.http.Context;
import io.javalin.http.NotFoundResponse;
import io.javalin.openapi.HttpMethod; import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi; import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent; import io.javalin.openapi.OpenApiContent;
@@ -21,11 +22,16 @@ import me.bobert.netneutrality.http.server.dto.CommandResultResponse;
import me.bobert.netneutrality.http.server.dto.ErrorResponse; import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.MusicSourceResponse; import me.bobert.netneutrality.http.server.dto.MusicSourceResponse;
import me.bobert.netneutrality.http.server.dto.MusicStatusResponse; import me.bobert.netneutrality.http.server.dto.MusicStatusResponse;
import me.bobert.netneutrality.http.server.dto.PlayRequest;
import me.bobert.netneutrality.http.server.dto.PlayResponse;
import me.bobert.netneutrality.http.server.dto.PlaySelectRequest;
import me.bobert.netneutrality.http.server.dto.QueuedTrackResponse; import me.bobert.netneutrality.http.server.dto.QueuedTrackResponse;
import me.bobert.netneutrality.http.server.dto.VolumeRequest; import me.bobert.netneutrality.http.server.dto.VolumeRequest;
import me.bobert.netneutrality.http.server.dto.VolumeResponse; import me.bobert.netneutrality.http.server.dto.VolumeResponse;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.PendingSearchResults;
import me.bobert.netneutrality.music.PlayOutcome;
import me.bobert.netneutrality.music.SourceFinder; import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.music.TrackInfo; import me.bobert.netneutrality.music.TrackInfo;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
@@ -35,7 +41,9 @@ import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier; import java.util.function.Supplier;
/** /**
@@ -56,8 +64,12 @@ public final class MusicRoutes {
routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(jdaSupplier, musicService, ctx));
routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx)); routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/play", ctx -> play(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/play/select/{searchId}", ctx -> playSelect(jdaSupplier, musicService, ctx));
} }
@OpenApi( @OpenApi(
@@ -96,6 +108,7 @@ public final class MusicRoutes {
body.put("channel_id", channel == null ? null : channel.getId()); body.put("channel_id", channel == null ? null : channel.getId());
body.put("channel_name", channel == null ? null : channel.getName()); body.put("channel_name", channel == null ? null : channel.getName());
body.put("playing", manager.isCurrentlyPlaying()); body.put("playing", manager.isCurrentlyPlaying());
body.put("paused", manager.isPaused());
body.put("current_track", trackSummary(manager.getCurrentlyPlaying())); body.put("current_track", trackSummary(manager.getCurrentlyPlaying()));
body.put("position_ms", manager.getCurrentPosition()); body.put("position_ms", manager.getCurrentPosition());
body.put("queue_size", manager.getQueueSize()); body.put("queue_size", manager.getQueueSize());
@@ -157,6 +170,40 @@ public final class MusicRoutes {
ctx.json(Map.of("status", "ok", "message", "Stopping current track")); ctx.json(Map.of("status", "ok", "message", "Stopping current track"));
} }
@OpenApi(
path = "/music/{guildId}/pause",
methods = HttpMethod.POST,
summary = "Pause the currently playing track, keeping its position",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = CommandResultResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void pause(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
manager.pauseTrack();
ctx.json(Map.of("status", "ok", "message", "Pausing current track"));
}
@OpenApi(
path = "/music/{guildId}/resume",
methods = HttpMethod.POST,
summary = "Resume a previously paused track from its current position",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = CommandResultResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void resumePlayback(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
manager.unpauseTrack();
ctx.json(Map.of("status", "ok", "message", "Resuming current track"));
}
@OpenApi( @OpenApi(
path = "/music/{guildId}/volume", path = "/music/{guildId}/volume",
methods = HttpMethod.PUT, methods = HttpMethod.PUT,
@@ -225,6 +272,142 @@ public final class MusicRoutes {
); );
} }
@OpenApi(
path = "/music/{guildId}/play",
methods = HttpMethod.POST,
summary = "Resolve a query and queue it for a guild member; the bot must already be connected to a voice channel in this guild",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = PlayRequest.class)),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = PlayResponse.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void play(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
MusicManager manager = musicService.get(guild);
PlayRequest body = ctx.bodyAsClass(PlayRequest.class);
String userId = requireNonBlank(body.userId(), "userId");
String query = requireNonBlank(body.query(), "query");
ctx.future(() -> resolveMember(guild, userId)
.thenCompose(member -> manager.playForMember(member, query))
.thenApply(outcome -> {
ctx.json(playOutcomeBody(outcome));
return null;
}));
}
@OpenApi(
path = "/music/{guildId}/play/select/{searchId}",
methods = HttpMethod.POST,
summary = "Queue a previously returned search candidate for a guild member",
tags = {"Music"},
pathParams = {
@OpenApiParam(name = "guildId", description = "The Discord guild id"),
@OpenApiParam(name = "searchId", description = "The search id returned by a previous /play call")
},
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = PlaySelectRequest.class)),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = PlayResponse.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void playSelect(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
MusicManager manager = musicService.get(guild);
String searchId = ctx.pathParam("searchId");
PlaySelectRequest body = ctx.bodyAsClass(PlaySelectRequest.class);
String userId = requireNonBlank(body.userId(), "userId");
List<Track> candidates = PendingSearchResults.consume(searchId, guild.getId());
if (candidates == null) {
throw new NotFoundResponse("Unknown or expired searchId");
}
if (body.index() < 0 || body.index() >= candidates.size()) {
throw new BadRequestResponse("index is out of range");
}
if (!manager.isConnected()) {
throw new BadRequestResponse("The bot isn't connected to a voice channel in this guild");
}
Track track = candidates.get(body.index());
ctx.future(() -> resolveMember(guild, userId)
.thenApply(member -> {
manager.getQueueManager().queue(track, member);
ctx.json(queuedBody(track, manager.getQueueSize()));
return null;
}));
}
/**
* Reads a non-blank field from a request body, failing with 400 when missing or blank.
*/
private static String requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BadRequestResponse(field + " is required");
}
return value;
}
/**
* Resolves a {@link Member} from cache, falling back to a REST call otherwise.
*/
private static CompletableFuture<Member> resolveMember(Guild guild, String userId) {
Member cached = guild.getMemberById(userId);
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return guild.retrieveMemberById(userId).submit();
}
/**
* Builds a JSON-friendly body for a {@link PlayOutcome}, throwing the matching
* {@code HttpResponseException} for outcomes that aren't successes.
*/
private static Map<String, Object> playOutcomeBody(PlayOutcome outcome) {
return switch (outcome) {
case PlayOutcome.Queued queued -> queuedBody(queued.track(), queued.queuePosition());
case PlayOutcome.PlaylistQueued playlistQueued -> {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", "playlist_queued");
body.put("queued_count", playlistQueued.queuedCount());
body.put("truncated", playlistQueued.truncated());
yield body;
}
case PlayOutcome.SearchCandidates searchCandidates -> {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", "search_results");
body.put("search_id", searchCandidates.searchId());
body.put("candidates", searchCandidates.candidates().stream().map(MusicRoutes::trackSummary).toList());
yield body;
}
case PlayOutcome.NotConnected ignored -> throw new BadRequestResponse(
"The bot isn't connected to a voice channel in this guild; a Discord command must join first");
case PlayOutcome.NoMatches ignored -> throw new NotFoundResponse("No matches found");
case PlayOutcome.Failed failed -> throw new BadRequestResponse(failed.message());
};
}
/**
* Builds a JSON-friendly body for a track that was just queued.
*/
private static Map<String, Object> queuedBody(Track track, int queuePosition) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", "queued");
body.put("track", trackSummary(track));
body.put("queue_position", queuePosition);
return body;
}
/** /**
* Resolves the {@link MusicManager} for the guild path parameter, creating it on first use. * Resolves the {@link MusicManager} for the guild path parameter, creating it on first use.
*/ */
@@ -14,6 +14,7 @@ public record MusicStatusResponse(
String channel_id, String channel_id,
String channel_name, String channel_name,
boolean playing, boolean playing,
boolean paused,
TrackSummaryResponse current_track, TrackSummaryResponse current_track,
long position_ms, long position_ms,
int queue_size int queue_size
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/play} request body.
*/
public record PlayRequest(
String userId,
String query
) {
}
@@ -0,0 +1,29 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.List;
/**
* OpenAPI schema for the {@code /music/{guildId}/play} and
* {@code /music/{guildId}/play/select/{searchId}} responses.
*
* <p>Fields are nullable since only a subset is populated depending on {@code status}:
* {@code queued} fills {@code track}/{@code queue_position}, {@code playlist_queued} fills
* {@code queued_count}/{@code truncated}, and {@code search_results} fills
* {@code search_id}/{@code candidates}.
*/
public record PlayResponse(
String status,
TrackSummaryResponse track,
Integer queue_position,
Integer queued_count,
Boolean truncated,
String search_id,
List<TrackSummaryResponse> candidates
) {
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/play/select/{searchId}} request body.
*/
public record PlaySelectRequest(
String userId,
int index
) {
}
@@ -25,6 +25,7 @@ import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
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.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
@@ -196,15 +197,13 @@ public class MusicManager implements MusicManagerCallback {
return; return;
} }
SourceFinder.SearchEngine searchEngine = parseSearchEngine(guildService); ResolvedSource source = resolveSource(query);
SourceFinder.AudioSourceType type = SourceFinder.getSource(query, searchEngine); if (source.type() == SourceFinder.AudioSourceType.Udio) {
if (type == SourceFinder.AudioSourceType.Udio) {
context.reply("Udio playback is temporarily unsupported since the migration to Lavalink"); context.reply("Udio playback is temporarily unsupported since the migration to Lavalink");
return; return;
} }
String identifier = SourceFinder.getIdentifier(query, searchEngine); if (source.identifier() == null) {
if (identifier == null) {
context.reply("No source found to play the given url"); context.reply("No source found to play the given url");
return; return;
} }
@@ -215,8 +214,8 @@ public class MusicManager implements MusicManagerCallback {
return; return;
} }
link.loadItem(identifier).subscribe( link.loadItem(source.identifier()).subscribe(
new TrackLoadingHandler(context, this, identifier, type, silent), new TrackLoadingHandler(context, this, source.identifier(), source.type(), silent),
error -> { error -> {
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error)); LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
if (!silent) { if (!silent) {
@@ -226,6 +225,70 @@ public class MusicManager implements MusicManagerCallback {
); );
} }
/**
* Member-driven counterpart of {@link #play(String, CommandContext, boolean)}, usable by
* non-command callers such as the REST API.
*
* <p>Only a Discord command can make the bot join a voice channel in the first place, so this
* method never attempts to connect: it fails fast with a {@link PlayOutcome.NotConnected}
* outcome when the bot isn't already connected for this guild.
*
* @param member the Discord member the track is queued on behalf of
* @param query the URL or search query to resolve
* @return a future completed with the outcome of the load/queue attempt
*/
public synchronized CompletableFuture<PlayOutcome> playForMember(Member member, String query) {
logger.debug("Guild {}: REST request to play '{}' by user {}", guild.getId(), query, member.getId());
CompletableFuture<PlayOutcome> future = new CompletableFuture<>();
if (!isConnected()) {
future.complete(new PlayOutcome.NotConnected());
return future;
}
ResolvedSource source = resolveSource(query);
if (source.type() == SourceFinder.AudioSourceType.Udio) {
future.complete(new PlayOutcome.Failed("Udio playback is temporarily unsupported since the migration to Lavalink"));
return future;
}
if (source.identifier() == null) {
future.complete(new PlayOutcome.Failed("No source found to play the given url"));
return future;
}
Link link = getLink();
if (link == null) {
future.complete(new PlayOutcome.Failed("The audio node is currently unavailable, please try again later"));
return future;
}
link.loadItem(source.identifier()).subscribe(
new RestTrackLoadingHandler(member, this, source.identifier(), future),
error -> {
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
future.completeExceptionally(error);
}
);
return future;
}
/**
* Identifier resolved from a raw query, shared by the command-driven and REST-driven play
* entry points.
*/
private record ResolvedSource(SourceFinder.AudioSourceType type, String identifier) {
}
private ResolvedSource resolveSource(String query) {
SourceFinder.SearchEngine searchEngine = parseSearchEngine(guildService);
SourceFinder.AudioSourceType type = SourceFinder.getSource(query, searchEngine);
String identifier = type == SourceFinder.AudioSourceType.Udio ? null : SourceFinder.getIdentifier(query, searchEngine);
return new ResolvedSource(type, identifier);
}
public synchronized void stopTrack() { public synchronized void stopTrack() {
updatePlayer("stop track", PlayerUpdateBuilder::stopTrack); updatePlayer("stop track", PlayerUpdateBuilder::stopTrack);
} }
@@ -234,6 +297,32 @@ public class MusicManager implements MusicManagerCallback {
return getQueueManager().resume(); return getQueueManager().resume();
} }
/**
* Pauses the currently playing track without losing its position, using the Lavalink node's
* pause state rather than stopping/restarting the track.
*/
public synchronized void pauseTrack() {
updatePlayer("pause track", builder -> builder.setPaused(true));
}
/**
* Unpauses a previously paused track, resuming playback from the position it was paused at.
*/
public synchronized void unpauseTrack() {
updatePlayer("unpause track", builder -> builder.setPaused(false));
}
/**
* Returns whether the player is currently paused on the Lavalink node.
*
* @return {@code true} when paused, {@code false} when playing or when no player is cached
*/
public boolean isPaused() {
Link link = getLink();
LavalinkPlayer player = link == null ? null : link.getCachedPlayer();
return player != null && player.getPaused();
}
public synchronized CommandExecutionResult voteSkip(CommandContext context) { public synchronized CommandExecutionResult voteSkip(CommandContext context) {
return voteSkip(context.getGuildUser()); return voteSkip(context.getGuildUser());
} }
@@ -0,0 +1,75 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory, TTL-based cache of ambiguous search results awaiting a REST caller's pick, keyed by
* a generated search id and scoped per guild.
*
* <p>Entries are short-lived selection prompts: they are removed as soon as they are consumed or
* once their TTL elapses, whichever happens first. No persistence is needed since a client can
* simply re-issue the search after a restart.
*/
public final class PendingSearchResults {
private static final Duration TTL = Duration.ofSeconds(60);
private static final Map<String, Entry> entries = new ConcurrentHashMap<>();
private PendingSearchResults() {
}
/**
* Stores the given candidates under a newly generated search id, scoped to {@code guildId}.
*
* @param guildId the guild the candidates belong to
* @param candidates the candidate tracks
* @return the generated search id
*/
public static String put(String guildId, List<Track> candidates) {
sweep();
String searchId = UUID.randomUUID().toString();
entries.put(searchId, new Entry(guildId, candidates, Instant.now().plus(TTL)));
return searchId;
}
/**
* Looks up and removes the candidates stored under {@code searchId} for {@code guildId}.
*
* @param searchId the search id to consume
* @param guildId the guild the lookup is scoped to
* @return the candidate tracks, or {@code null} when the search id is unknown, expired, or
* belongs to a different guild
*/
public static List<Track> consume(String searchId, String guildId) {
sweep();
Entry entry = entries.remove(searchId);
if (entry == null || !entry.guildId().equals(guildId) || Instant.now().isAfter(entry.expiresAt())) {
return null;
}
return entry.candidates();
}
private static void sweep() {
Instant now = Instant.now();
entries.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiresAt()));
}
private record Entry(String guildId, List<Track> candidates, Instant expiresAt) {
}
}
@@ -0,0 +1,37 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import java.util.List;
/**
* Outcome of a {@link MusicManager#playForMember(net.dv8tion.jda.api.entities.Member, String)}
* call, used to report the result of a REST-triggered play request without any Discord message
* context.
*/
public sealed interface PlayOutcome {
record Queued(Track track, int queuePosition) implements PlayOutcome {
}
record PlaylistQueued(int queuedCount, boolean truncated) implements PlayOutcome {
}
record SearchCandidates(String searchId, List<Track> candidates) implements PlayOutcome {
}
record NotConnected() implements PlayOutcome {
}
record NoMatches() implements PlayOutcome {
}
record Failed(String message) implements PlayOutcome {
}
}
@@ -0,0 +1,121 @@
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 me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.db.GSettings;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* Translates a Lavalink load result into queue operations and a {@link PlayOutcome}, without any
* Discord message context.
*
* <p>Mirrors {@link TrackLoadingHandler}'s branching but completes a future instead of replying
* on a {@link me.bobert.netneutrality.core.context.CommandContext}: direct tracks and playlists
* are queued immediately, while ambiguous search results are stashed in
* {@link PendingSearchResults} for a later {@code /play/select} call instead of being queued.
*/
public class RestTrackLoadingHandler extends AbstractAudioLoadResultHandler {
private static final Logger logger = LogManager.getLogger(RestTrackLoadingHandler.class);
private static final int SEARCH_RESULTS_LIMIT = 5;
private final String identifier;
private final Guild guild;
private final QueueManager queueManager;
private final Member member;
private final MusicManager manager;
private final CompletableFuture<PlayOutcome> future;
public RestTrackLoadingHandler(Member member, MusicManager manager, String identifier, CompletableFuture<PlayOutcome> future) {
this.member = member;
this.manager = manager;
this.identifier = identifier;
this.guild = manager.getGuild();
this.queueManager = manager.getQueueManager();
this.future = future;
}
@Override
public void ontrackLoaded(TrackLoaded result) {
Track track = result.getTrack();
logger.debug("Guild {}: track loaded: {}", guild.getId(), track.getInfo().getIdentifier());
queueManager.queue(track, member);
future.complete(new PlayOutcome.Queued(track, queueManager.getQueueSize()));
}
@Override
public void onPlaylistLoaded(PlaylistLoaded result) {
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
List<Track> tracks = result.getTracks();
int maxPlaylistSize = parseMaxPlaylistSize();
boolean truncated = tracks.size() > maxPlaylistSize;
List<Track> toQueue = truncated ? tracks.subList(0, maxPlaylistSize) : tracks;
queueManager.queue(toQueue, member);
future.complete(new PlayOutcome.PlaylistQueued(toQueue.size(), truncated));
}
/**
* Search results are not queued: the candidates are stashed under a generated search id and
* left for a REST caller to confirm via {@code /play/select}, since there is no interactive
* UI to pick one over HTTP.
*/
@Override
public void onSearchResultLoaded(SearchResult result) {
List<Track> tracks = result.getTracks();
logger.debug("Guild {}: search returned {} results for {}", guild.getId(), tracks.size(), identifier);
if (tracks.isEmpty()) {
noMatches();
return;
}
List<Track> candidates = tracks.stream().limit(SEARCH_RESULTS_LIMIT).toList();
String searchId = PendingSearchResults.put(guild.getId(), candidates);
future.complete(new PlayOutcome.SearchCandidates(searchId, candidates));
}
@Override
public void noMatches() {
logger.debug("Guild {}: no matches for identifier {}", guild.getId(), identifier);
future.complete(new PlayOutcome.NoMatches());
}
@Override
public void loadFailed(LoadFailed result) {
logger.debug("Guild {}: load failed for identifier {}", guild.getId(), identifier);
RuntimeException exception = new RuntimeException("Failed to load track: " + result.getException().getMessage());
LogHandler.log(exception);
future.complete(new PlayOutcome.Failed(exception.getMessage()));
}
private int parseMaxPlaylistSize() {
try {
return Integer.parseInt(manager.getGuildService().getGuildSettingService().getOrDefault(GSettings.MUSIC_MAXPLAYLISTSIZE));
} catch (NumberFormatException e) {
return Integer.parseInt(GSettings.MUSIC_MAXPLAYLISTSIZE.getDefaultValue());
}
}
}