Refactor REST API music playback flow
Replaced `/play` and `/play/select` endpoints with more streamlined `/search` and `/queue` routes. Updated `MusicManager` and related components to separate query resolution from playback queuing, requiring explicit confirmation for each candidate. Introduced `SearchRequest`, `SearchResultHandler`, and revamped `PlayOutcome` to improve clarity and flexibility.
This commit is contained in:
1 parent
0e68886eb2
commit
a605a834b4
8 files changed
+172
-114
No files matched your search
@@ -24,8 +24,8 @@ 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.PlayRequest;
|
||||||
import me.bobert.netneutrality.http.server.dto.PlayResponse;
|
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.SearchRequest;
|
||||||
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;
|
||||||
@@ -68,8 +68,8 @@ public final class MusicRoutes {
|
|||||||
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(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}/queue", ctx -> play(jdaSupplier, musicService, ctx));
|
||||||
routes.post("/music/{guildId}/play/select/{searchId}", ctx -> playSelect(jdaSupplier, musicService, ctx));
|
routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@OpenApi(
|
@OpenApi(
|
||||||
@@ -273,9 +273,9 @@ public final class MusicRoutes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OpenApi(
|
@OpenApi(
|
||||||
path = "/music/{guildId}/play",
|
path = "/music/{guildId}/queue",
|
||||||
methods = HttpMethod.POST,
|
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",
|
summary = "Queue a previously returned search candidate by passing the searchId/index returned by /music/{guildId}/search; the bot must already be connected to a voice channel in this guild",
|
||||||
tags = {"Music"},
|
tags = {"Music"},
|
||||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||||
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = PlayRequest.class)),
|
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = PlayRequest.class)),
|
||||||
@@ -290,45 +290,71 @@ public final class MusicRoutes {
|
|||||||
MusicManager manager = musicService.get(guild);
|
MusicManager manager = musicService.get(guild);
|
||||||
PlayRequest body = ctx.bodyAsClass(PlayRequest.class);
|
PlayRequest body = ctx.bodyAsClass(PlayRequest.class);
|
||||||
String userId = requireNonBlank(body.userId(), "userId");
|
String userId = requireNonBlank(body.userId(), "userId");
|
||||||
String query = requireNonBlank(body.query(), "query");
|
requireNonBlank(body.searchId(), "searchId");
|
||||||
|
|
||||||
ctx.future(() -> resolveMember(guild, userId)
|
playSelect(guild, manager, body, userId, ctx);
|
||||||
.thenCompose(member -> manager.playForMember(member, query))
|
|
||||||
.thenApply(outcome -> {
|
|
||||||
ctx.json(playOutcomeBody(outcome));
|
|
||||||
return null;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OpenApi(
|
@OpenApi(
|
||||||
path = "/music/{guildId}/play/select/{searchId}",
|
path = "/music/{guildId}/search",
|
||||||
methods = HttpMethod.POST,
|
methods = HttpMethod.POST,
|
||||||
summary = "Queue a previously returned search candidate for a guild member",
|
summary = "Resolve a query into candidates without queuing anything, optionally selecting a searchEngine instead of prefixing the query; every result, including a single direct match, must be confirmed via /queue with searchId/index",
|
||||||
tags = {"Music"},
|
tags = {"Music"},
|
||||||
pathParams = {
|
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||||
@OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = SearchRequest.class)),
|
||||||
@OpenApiParam(name = "searchId", description = "The search id returned by a previous /play call")
|
|
||||||
},
|
|
||||||
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = PlaySelectRequest.class)),
|
|
||||||
responses = {
|
responses = {
|
||||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = PlayResponse.class)),
|
@OpenApiResponse(status = "200", content = @OpenApiContent(from = PlayResponse.class)),
|
||||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
||||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
private static void playSelect(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
private static void search(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||||
MusicManager manager = musicService.get(guild);
|
MusicManager manager = musicService.get(guild);
|
||||||
String searchId = ctx.pathParam("searchId");
|
SearchRequest body = ctx.bodyAsClass(SearchRequest.class);
|
||||||
PlaySelectRequest body = ctx.bodyAsClass(PlaySelectRequest.class);
|
String query = requireNonBlank(body.query(), "query");
|
||||||
String userId = requireNonBlank(body.userId(), "userId");
|
SourceFinder.SearchEngine searchEngine = parseSearchEngine(body.searchEngine());
|
||||||
|
|
||||||
|
ctx.future(() -> manager.search(query, searchEngine).thenApply(outcome -> {
|
||||||
|
ctx.json(playOutcomeBody(outcome));
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the optional {@code searchEngine} field of a {@link SearchRequest}, failing with
|
||||||
|
* 400 when it doesn't match a known {@link SourceFinder.SearchEngine}.
|
||||||
|
*/
|
||||||
|
private static SourceFinder.SearchEngine parseSearchEngine(String searchEngine) {
|
||||||
|
if (searchEngine == null || searchEngine.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return SourceFinder.SearchEngine.valueOf(searchEngine.toUpperCase(java.util.Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new BadRequestResponse("searchEngine must be one of " + Arrays.toString(SourceFinder.SearchEngine.values()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a previously returned search candidate for a guild member, invoked from {@link
|
||||||
|
* #play(Supplier, MusicService, Context)} when the request body carries a {@code searchId}.
|
||||||
|
* Exposed via the {@code /music/{guildId}/queue} POST endpoint.
|
||||||
|
*/
|
||||||
|
private static void playSelect(Guild guild, MusicManager manager, PlayRequest body, String userId, Context ctx) {
|
||||||
|
String searchId = body.searchId();
|
||||||
|
Integer index = body.index();
|
||||||
|
if (index == null) {
|
||||||
|
throw new BadRequestResponse("index is required when searchId is provided");
|
||||||
|
}
|
||||||
|
|
||||||
List<Track> candidates = PendingSearchResults.consume(searchId, guild.getId());
|
List<Track> candidates = PendingSearchResults.consume(searchId, guild.getId());
|
||||||
if (candidates == null) {
|
if (candidates == null) {
|
||||||
throw new NotFoundResponse("Unknown or expired searchId");
|
throw new NotFoundResponse("Unknown or expired searchId");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.index() < 0 || body.index() >= candidates.size()) {
|
if (index < 0 || index >= candidates.size()) {
|
||||||
throw new BadRequestResponse("index is out of range");
|
throw new BadRequestResponse("index is out of range");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +362,7 @@ public final class MusicRoutes {
|
|||||||
throw new BadRequestResponse("The bot isn't connected to a voice channel in this guild");
|
throw new BadRequestResponse("The bot isn't connected to a voice channel in this guild");
|
||||||
}
|
}
|
||||||
|
|
||||||
Track track = candidates.get(body.index());
|
Track track = candidates.get(index);
|
||||||
|
|
||||||
ctx.future(() -> resolveMember(guild, userId)
|
ctx.future(() -> resolveMember(guild, userId)
|
||||||
.thenApply(member -> {
|
.thenApply(member -> {
|
||||||
@@ -375,19 +401,26 @@ public final class MusicRoutes {
|
|||||||
*/
|
*/
|
||||||
private static Map<String, Object> playOutcomeBody(PlayOutcome outcome) {
|
private static Map<String, Object> playOutcomeBody(PlayOutcome outcome) {
|
||||||
return switch (outcome) {
|
return switch (outcome) {
|
||||||
case PlayOutcome.Queued queued -> queuedBody(queued.track(), queued.queuePosition());
|
case PlayOutcome.PendingTrack pendingTrack -> {
|
||||||
case PlayOutcome.PlaylistQueued playlistQueued -> {
|
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("status", "playlist_queued");
|
body.put("status", "pending_track");
|
||||||
body.put("queued_count", playlistQueued.queuedCount());
|
body.put("search_id", pendingTrack.searchId());
|
||||||
body.put("truncated", playlistQueued.truncated());
|
body.put("track", trackSummary(pendingTrack.track()));
|
||||||
yield body;
|
yield body;
|
||||||
}
|
}
|
||||||
case PlayOutcome.SearchCandidates searchCandidates -> {
|
case PlayOutcome.PendingPlaylist pendingPlaylist -> {
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("status", "search_results");
|
body.put("status", "pending_playlist");
|
||||||
body.put("search_id", searchCandidates.searchId());
|
body.put("search_id", pendingPlaylist.searchId());
|
||||||
body.put("candidates", searchCandidates.candidates().stream().map(MusicRoutes::trackSummary).toList());
|
body.put("tracks", pendingPlaylist.tracks().stream().map(MusicRoutes::trackSummary).toList());
|
||||||
|
body.put("truncated", pendingPlaylist.truncated());
|
||||||
|
yield body;
|
||||||
|
}
|
||||||
|
case PlayOutcome.PendingSearch pendingSearch -> {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("status", "pending_search");
|
||||||
|
body.put("search_id", pendingSearch.searchId());
|
||||||
|
body.put("candidates", pendingSearch.candidates().stream().map(MusicRoutes::trackSummary).toList());
|
||||||
yield body;
|
yield body;
|
||||||
}
|
}
|
||||||
case PlayOutcome.NotConnected ignored -> throw new BadRequestResponse(
|
case PlayOutcome.NotConnected ignored -> throw new BadRequestResponse(
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ package me.bobert.netneutrality.http.server.dto;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAPI schema for the {@code /music/{guildId}/play} request body.
|
* OpenAPI schema for the {@code /music/{guildId}/queue} request body.
|
||||||
|
*
|
||||||
|
* @param userId the Discord user id to credit as the requester
|
||||||
|
* @param searchId a search id previously returned by {@code /music/{guildId}/search}
|
||||||
|
* @param index the index of the candidate to queue within the {@code searchId}'s results
|
||||||
*/
|
*/
|
||||||
public record PlayRequest(
|
public record PlayRequest(
|
||||||
String userId,
|
String userId,
|
||||||
String query
|
String searchId,
|
||||||
|
Integer index
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -9,21 +9,24 @@ package me.bobert.netneutrality.http.server.dto;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAPI schema for the {@code /music/{guildId}/play} and
|
* OpenAPI schema for the {@code /music/{guildId}/queue} and {@code /music/{guildId}/search}
|
||||||
* {@code /music/{guildId}/play/select/{searchId}} responses.
|
* responses.
|
||||||
*
|
*
|
||||||
* <p>Fields are nullable since only a subset is populated depending on {@code status}:
|
* <p>Nothing is ever queued directly by {@code /music/{guildId}/search}: every resolved query is
|
||||||
* {@code queued} fills {@code track}/{@code queue_position}, {@code playlist_queued} fills
|
* stashed as a pending candidate, to be confirmed via a follow-up {@code /music/{guildId}/queue}
|
||||||
* {@code queued_count}/{@code truncated}, and {@code search_results} fills
|
* call carrying {@code searchId}/{@code index}. Fields are nullable since only a subset is populated depending
|
||||||
* {@code search_id}/{@code candidates}.
|
* on {@code status}: {@code queued} fills {@code track}/{@code queue_position} (confirmed
|
||||||
|
* selection), {@code pending_track} fills {@code search_id}/{@code track}, {@code
|
||||||
|
* pending_playlist} fills {@code search_id}/{@code tracks}/{@code truncated}, and {@code
|
||||||
|
* pending_search} fills {@code search_id}/{@code candidates}.
|
||||||
*/
|
*/
|
||||||
public record PlayResponse(
|
public record PlayResponse(
|
||||||
String status,
|
String status,
|
||||||
TrackSummaryResponse track,
|
TrackSummaryResponse track,
|
||||||
Integer queue_position,
|
Integer queue_position,
|
||||||
Integer queued_count,
|
|
||||||
Boolean truncated,
|
|
||||||
String search_id,
|
String search_id,
|
||||||
|
List<TrackSummaryResponse> tracks,
|
||||||
|
Boolean truncated,
|
||||||
List<TrackSummaryResponse> candidates
|
List<TrackSummaryResponse> candidates
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package me.bobert.netneutrality.http.server.dto;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI schema for the {@code /music/{guildId}/search} request body.
|
||||||
|
*
|
||||||
|
* @param query the identifier or search query to resolve
|
||||||
|
* @param searchEngine the {@link me.bobert.netneutrality.music.SourceFinder.SearchEngine} name
|
||||||
|
* to use for non-url queries (e.g. {@code "YTMUSIC"}), or {@code null} to
|
||||||
|
* use the guild's configured default; lets callers select an engine
|
||||||
|
* instead of manually prefixing the query
|
||||||
|
*/
|
||||||
|
public record SearchRequest(
|
||||||
|
String query,
|
||||||
|
String searchEngine
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -226,28 +226,38 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Member-driven counterpart of {@link #play(String, CommandContext, boolean)}, usable by
|
* Resolves a query without queuing anything, always returning a {@link PlayOutcome} that
|
||||||
* non-command callers such as the REST API.
|
* surfaces candidates to be confirmed via the {@code /music/{guildId}/queue} endpoint's
|
||||||
|
* {@code searchId}/{@code index} selection.
|
||||||
*
|
*
|
||||||
* <p>Only a Discord command can make the bot join a voice channel in the first place, so this
|
* <p>Nothing is ever queued directly by this method: every resolved query, whether a single
|
||||||
* method never attempts to connect: it fails fast with a {@link PlayOutcome.NotConnected}
|
* track, a playlist, or an ambiguous search, requires a separate call carrying the returned
|
||||||
* outcome when the bot isn't already connected for this guild.
|
* {@code searchId}/{@code index} to actually add it to the queue.
|
||||||
*
|
*
|
||||||
* @param member the Discord member the track is queued on behalf of
|
|
||||||
* @param query the URL or search query to resolve
|
* @param query the URL or search query to resolve
|
||||||
* @return a future completed with the outcome of the load/queue attempt
|
* @return a future completed with the search outcome
|
||||||
*/
|
*/
|
||||||
public synchronized CompletableFuture<PlayOutcome> playForMember(Member member, String query) {
|
public synchronized CompletableFuture<PlayOutcome> search(String query) {
|
||||||
logger.debug("Guild {}: REST request to play '{}' by user {}", guild.getId(), query, member.getId());
|
return search(query, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #search(String)}, but allows explicitly selecting which
|
||||||
|
* {@link SourceFinder.SearchEngine} is used to resolve a non-url {@code query}, instead of
|
||||||
|
* relying on the guild's configured default or a manually prefixed identifier
|
||||||
|
* (e.g. {@code ytmsearch:}).
|
||||||
|
*
|
||||||
|
* @param query the identifier or search query to resolve
|
||||||
|
* @param searchEngine the search engine to use for non-url queries, or {@code null} to use
|
||||||
|
* the guild's configured default
|
||||||
|
* @return a future completed with the search outcome
|
||||||
|
*/
|
||||||
|
public synchronized CompletableFuture<PlayOutcome> search(String query, SourceFinder.SearchEngine searchEngine) {
|
||||||
|
logger.debug("Guild {}: REST request to search '{}' using engine {}", guild.getId(), query, searchEngine);
|
||||||
|
|
||||||
CompletableFuture<PlayOutcome> future = new CompletableFuture<>();
|
CompletableFuture<PlayOutcome> future = new CompletableFuture<>();
|
||||||
|
|
||||||
if (!isConnected()) {
|
ResolvedSource source = resolveSource(query, searchEngine);
|
||||||
future.complete(new PlayOutcome.NotConnected());
|
|
||||||
return future;
|
|
||||||
}
|
|
||||||
|
|
||||||
ResolvedSource source = resolveSource(query);
|
|
||||||
if (source.type() == SourceFinder.AudioSourceType.Udio) {
|
if (source.type() == SourceFinder.AudioSourceType.Udio) {
|
||||||
future.complete(new PlayOutcome.Failed("Udio playback is temporarily unsupported since the migration to Lavalink"));
|
future.complete(new PlayOutcome.Failed("Udio playback is temporarily unsupported since the migration to Lavalink"));
|
||||||
return future;
|
return future;
|
||||||
@@ -265,7 +275,7 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
link.loadItem(source.identifier()).subscribe(
|
link.loadItem(source.identifier()).subscribe(
|
||||||
new RestTrackLoadingHandler(member, this, source.identifier(), future),
|
new SearchResultHandler(this, source.identifier(), future),
|
||||||
error -> {
|
error -> {
|
||||||
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
|
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
|
||||||
future.completeExceptionally(error);
|
future.completeExceptionally(error);
|
||||||
@@ -283,9 +293,17 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ResolvedSource resolveSource(String query) {
|
private ResolvedSource resolveSource(String query) {
|
||||||
SourceFinder.SearchEngine searchEngine = parseSearchEngine(guildService);
|
return resolveSource(query, null);
|
||||||
SourceFinder.AudioSourceType type = SourceFinder.getSource(query, searchEngine);
|
}
|
||||||
String identifier = type == SourceFinder.AudioSourceType.Udio ? null : SourceFinder.getIdentifier(query, searchEngine);
|
|
||||||
|
/**
|
||||||
|
* Resolves a query's source, using {@code searchEngine} when explicitly provided instead of
|
||||||
|
* the guild's configured default.
|
||||||
|
*/
|
||||||
|
private ResolvedSource resolveSource(String query, SourceFinder.SearchEngine searchEngine) {
|
||||||
|
SourceFinder.SearchEngine effectiveSearchEngine = searchEngine != null ? searchEngine : parseSearchEngine(guildService);
|
||||||
|
SourceFinder.AudioSourceType type = SourceFinder.getSource(query, effectiveSearchEngine);
|
||||||
|
String identifier = type == SourceFinder.AudioSourceType.Udio ? null : SourceFinder.getIdentifier(query, effectiveSearchEngine);
|
||||||
return new ResolvedSource(type, identifier);
|
return new ResolvedSource(type, identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,22 @@ import dev.arbjerg.lavalink.client.player.Track;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outcome of a {@link MusicManager#playForMember(net.dv8tion.jda.api.entities.Member, String)}
|
* Outcome of a {@link MusicManager#search(String)} call, used to report the result of a
|
||||||
* call, used to report the result of a REST-triggered play request without any Discord message
|
* REST-triggered load request without any Discord message context.
|
||||||
* context.
|
*
|
||||||
|
* <p>Nothing is ever queued directly: every resolved query, whether a single track, a playlist,
|
||||||
|
* or an ambiguous search, is stashed in {@link PendingSearchResults} and must be confirmed via a
|
||||||
|
* separate {@code /play} call carrying the returned {@code searchId}/{@code index}.
|
||||||
*/
|
*/
|
||||||
public sealed interface PlayOutcome {
|
public sealed interface PlayOutcome {
|
||||||
|
|
||||||
record Queued(Track track, int queuePosition) implements PlayOutcome {
|
record PendingTrack(String searchId, Track track) implements PlayOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
record PlaylistQueued(int queuedCount, boolean truncated) implements PlayOutcome {
|
record PendingPlaylist(String searchId, List<Track> tracks, boolean truncated) implements PlayOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
record SearchCandidates(String searchId, List<Track> candidates) implements PlayOutcome {
|
record PendingSearch(String searchId, List<Track> candidates) implements PlayOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
record NotConnected() implements PlayOutcome {
|
record NotConnected() implements PlayOutcome {
|
||||||
|
|||||||
+19
-29
@@ -15,7 +15,6 @@ import dev.arbjerg.lavalink.client.player.TrackLoaded;
|
|||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.db.GSettings;
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
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.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
@@ -23,61 +22,53 @@ import java.util.List;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates a Lavalink load result into queue operations and a {@link PlayOutcome}, without any
|
* Translates a Lavalink load result into a {@link PlayOutcome} without ever queuing anything, so
|
||||||
* Discord message context.
|
* that every REST load request, not only ambiguous text searches, requires a separate
|
||||||
|
* {@code /play} call with {@code searchId}/{@code index} to actually queue a track.
|
||||||
*
|
*
|
||||||
* <p>Mirrors {@link TrackLoadingHandler}'s branching but completes a future instead of replying
|
* <p>A single resolved track, the tracks of a playlist, and ambiguous search results are each
|
||||||
* on a {@link me.bobert.netneutrality.core.context.CommandContext}: direct tracks and playlists
|
* stashed as candidates in {@link PendingSearchResults} and reported back with their own
|
||||||
* are queued immediately, while ambiguous search results are stashed in
|
* {@link PlayOutcome.PendingTrack}, {@link PlayOutcome.PendingPlaylist}, or
|
||||||
* {@link PendingSearchResults} for a later {@code /play/select} call instead of being queued.
|
* {@link PlayOutcome.PendingSearch} outcome, respectively.
|
||||||
*/
|
*/
|
||||||
public class RestTrackLoadingHandler extends AbstractAudioLoadResultHandler {
|
public class SearchResultHandler extends AbstractAudioLoadResultHandler {
|
||||||
private static final Logger logger = LogManager.getLogger(RestTrackLoadingHandler.class);
|
private static final Logger logger = LogManager.getLogger(SearchResultHandler.class);
|
||||||
private static final int SEARCH_RESULTS_LIMIT = 5;
|
private static final int SEARCH_RESULTS_LIMIT = 5;
|
||||||
|
|
||||||
private final String identifier;
|
private final String identifier;
|
||||||
private final Guild guild;
|
private final Guild guild;
|
||||||
private final QueueManager queueManager;
|
|
||||||
private final Member member;
|
|
||||||
private final MusicManager manager;
|
private final MusicManager manager;
|
||||||
private final CompletableFuture<PlayOutcome> future;
|
private final CompletableFuture<PlayOutcome> future;
|
||||||
|
|
||||||
public RestTrackLoadingHandler(Member member, MusicManager manager, String identifier, CompletableFuture<PlayOutcome> future) {
|
public SearchResultHandler(MusicManager manager, String identifier, CompletableFuture<PlayOutcome> future) {
|
||||||
this.member = member;
|
|
||||||
this.manager = manager;
|
|
||||||
this.identifier = identifier;
|
this.identifier = identifier;
|
||||||
this.guild = manager.getGuild();
|
this.guild = manager.getGuild();
|
||||||
this.queueManager = manager.getQueueManager();
|
this.manager = manager;
|
||||||
this.future = future;
|
this.future = future;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void ontrackLoaded(TrackLoaded result) {
|
public void ontrackLoaded(TrackLoaded result) {
|
||||||
Track track = result.getTrack();
|
Track track = result.getTrack();
|
||||||
logger.debug("Guild {}: track loaded: {}", guild.getId(), track.getInfo().getIdentifier());
|
logger.debug("Guild {}: track resolved: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||||
|
|
||||||
queueManager.queue(track, member);
|
String searchId = PendingSearchResults.put(guild.getId(), List.of(track));
|
||||||
future.complete(new PlayOutcome.Queued(track, queueManager.getQueueSize()));
|
future.complete(new PlayOutcome.PendingTrack(searchId, track));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlaylistLoaded(PlaylistLoaded result) {
|
public void onPlaylistLoaded(PlaylistLoaded result) {
|
||||||
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
|
logger.debug("Guild {}: playlist resolved: {}", guild.getId(), identifier);
|
||||||
|
|
||||||
List<Track> tracks = result.getTracks();
|
List<Track> tracks = result.getTracks();
|
||||||
int maxPlaylistSize = parseMaxPlaylistSize();
|
int maxPlaylistSize = parseMaxPlaylistSize();
|
||||||
boolean truncated = tracks.size() > maxPlaylistSize;
|
boolean truncated = tracks.size() > maxPlaylistSize;
|
||||||
List<Track> toQueue = truncated ? tracks.subList(0, maxPlaylistSize) : tracks;
|
List<Track> candidates = truncated ? tracks.subList(0, maxPlaylistSize) : tracks;
|
||||||
|
|
||||||
queueManager.queue(toQueue, member);
|
String searchId = PendingSearchResults.put(guild.getId(), candidates);
|
||||||
future.complete(new PlayOutcome.PlaylistQueued(toQueue.size(), truncated));
|
future.complete(new PlayOutcome.PendingPlaylist(searchId, candidates, 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
|
@Override
|
||||||
public void onSearchResultLoaded(SearchResult result) {
|
public void onSearchResultLoaded(SearchResult result) {
|
||||||
List<Track> tracks = result.getTracks();
|
List<Track> tracks = result.getTracks();
|
||||||
@@ -90,8 +81,7 @@ public class RestTrackLoadingHandler extends AbstractAudioLoadResultHandler {
|
|||||||
|
|
||||||
List<Track> candidates = tracks.stream().limit(SEARCH_RESULTS_LIMIT).toList();
|
List<Track> candidates = tracks.stream().limit(SEARCH_RESULTS_LIMIT).toList();
|
||||||
String searchId = PendingSearchResults.put(guild.getId(), candidates);
|
String searchId = PendingSearchResults.put(guild.getId(), candidates);
|
||||||
|
future.complete(new PlayOutcome.PendingSearch(searchId, candidates));
|
||||||
future.complete(new PlayOutcome.SearchCandidates(searchId, candidates));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
Reference in new issue
Block a user