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:
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.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<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(
|
||||
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<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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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.
|
||||
*
|
||||
* <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() {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user