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 fbc3bb8..781102d 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java @@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.player.Track; import io.javalin.config.RoutesConfig; import io.javalin.http.BadRequestResponse; import io.javalin.http.Context; +import io.javalin.http.NotFoundResponse; import io.javalin.openapi.HttpMethod; import io.javalin.openapi.OpenApi; 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.MusicSourceResponse; 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.VolumeRequest; import me.bobert.netneutrality.http.server.dto.VolumeResponse; import me.bobert.netneutrality.music.MusicManager; 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.TrackInfo; 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.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; /** @@ -56,8 +64,12 @@ public final class MusicRoutes { routes.get("/music/{guildId}/queue", ctx -> queue(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}/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.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( @@ -96,6 +108,7 @@ public final class MusicRoutes { body.put("channel_id", channel == null ? null : channel.getId()); body.put("channel_name", channel == null ? null : channel.getName()); body.put("playing", manager.isCurrentlyPlaying()); + body.put("paused", manager.isPaused()); body.put("current_track", trackSummary(manager.getCurrentlyPlaying())); body.put("position_ms", manager.getCurrentPosition()); body.put("queue_size", manager.getQueueSize()); @@ -157,6 +170,40 @@ public final class MusicRoutes { 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 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 jdaSupplier, MusicService musicService, Context ctx) { + MusicManager manager = manager(jdaSupplier, musicService, ctx); + manager.unpauseTrack(); + ctx.json(Map.of("status", "ok", "message", "Resuming current track")); + } + @OpenApi( path = "/music/{guildId}/volume", 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 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 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 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 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 playOutcomeBody(PlayOutcome outcome) { + return switch (outcome) { + case PlayOutcome.Queued queued -> queuedBody(queued.track(), queued.queuePosition()); + case PlayOutcome.PlaylistQueued playlistQueued -> { + Map 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 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 queuedBody(Track track, int queuePosition) { + Map 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. */ diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/MusicStatusResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/MusicStatusResponse.java index 99d81d9..ac438b2 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/MusicStatusResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/MusicStatusResponse.java @@ -14,6 +14,7 @@ public record MusicStatusResponse( String channel_id, String channel_name, boolean playing, + boolean paused, TrackSummaryResponse current_track, long position_ms, int queue_size diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/PlayRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/PlayRequest.java new file mode 100644 index 0000000..692cd1a --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/PlayRequest.java @@ -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 +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/PlayResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/PlayResponse.java new file mode 100644 index 0000000..6812416 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/PlayResponse.java @@ -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. + * + *

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 candidates +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/PlaySelectRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/PlaySelectRequest.java new file mode 100644 index 0000000..4f69fca --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/PlaySelectRequest.java @@ -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 +) { +} diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java index 6458667..509138e 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java @@ -25,6 +25,7 @@ import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; /** @@ -196,15 +197,13 @@ public class MusicManager implements MusicManagerCallback { return; } - SourceFinder.SearchEngine searchEngine = parseSearchEngine(guildService); - SourceFinder.AudioSourceType type = SourceFinder.getSource(query, searchEngine); - if (type == SourceFinder.AudioSourceType.Udio) { + ResolvedSource source = resolveSource(query); + if (source.type() == SourceFinder.AudioSourceType.Udio) { context.reply("Udio playback is temporarily unsupported since the migration to Lavalink"); return; } - String identifier = SourceFinder.getIdentifier(query, searchEngine); - if (identifier == null) { + if (source.identifier() == null) { context.reply("No source found to play the given url"); return; } @@ -215,8 +214,8 @@ public class MusicManager implements MusicManagerCallback { return; } - link.loadItem(identifier).subscribe( - new TrackLoadingHandler(context, this, identifier, type, silent), + link.loadItem(source.identifier()).subscribe( + new TrackLoadingHandler(context, this, source.identifier(), source.type(), silent), error -> { LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error)); 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. + * + *

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 playForMember(Member member, String query) { + logger.debug("Guild {}: REST request to play '{}' by user {}", guild.getId(), query, member.getId()); + + CompletableFuture 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() { updatePlayer("stop track", PlayerUpdateBuilder::stopTrack); } @@ -234,6 +297,32 @@ public class MusicManager implements MusicManagerCallback { 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) { return voteSkip(context.getGuildUser()); } diff --git a/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java b/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java new file mode 100644 index 0000000..f933b10 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java @@ -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. + * + *

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 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 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 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 candidates, Instant expiresAt) { + } +} diff --git a/src/main/java/me/bobert/netneutrality/music/PlayOutcome.java b/src/main/java/me/bobert/netneutrality/music/PlayOutcome.java new file mode 100644 index 0000000..f17d8f1 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/PlayOutcome.java @@ -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 candidates) implements PlayOutcome { + } + + record NotConnected() implements PlayOutcome { + } + + record NoMatches() implements PlayOutcome { + } + + record Failed(String message) implements PlayOutcome { + } +} diff --git a/src/main/java/me/bobert/netneutrality/music/RestTrackLoadingHandler.java b/src/main/java/me/bobert/netneutrality/music/RestTrackLoadingHandler.java new file mode 100644 index 0000000..35cc049 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/RestTrackLoadingHandler.java @@ -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. + * + *

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 future; + + public RestTrackLoadingHandler(Member member, MusicManager manager, String identifier, CompletableFuture 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 tracks = result.getTracks(); + int maxPlaylistSize = parseMaxPlaylistSize(); + boolean truncated = tracks.size() > maxPlaylistSize; + List 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 tracks = result.getTracks(); + logger.debug("Guild {}: search returned {} results for {}", guild.getId(), tracks.size(), identifier); + + if (tracks.isEmpty()) { + noMatches(); + return; + } + + List 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()); + } + } +}