Add seek and reorder queue functionality to music routes
Introduced `/music/{guildId}/seek` and `/music/{guildId}/queue/reorder` endpoints, enabling users to seek to a specific position in the currently playing track and reorder tracks in the queue. Added corresponding request/response DTOs and OpenAPI documentation. Updated `QueueManager` with a `reorder` method and added comprehensive unit tests for edge cases and validation.
This commit is contained in:
1 parent
470f5a33aa
commit
30ff1602b2
8 files changed
+277
No files matched your search
@@ -24,7 +24,11 @@ import me.bobert.netneutrality.http.server.dto.MusicStatusResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.QueueRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.QueueResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.QueuedTrackResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.ReorderRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.ReorderResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.SearchRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.SeekRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.SeekResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.VolumeRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.VolumeResponse;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
@@ -67,8 +71,10 @@ public final class MusicRoutes {
|
||||
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}/seek", ctx -> seek(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/queue", ctx -> play(jdaSupplier, musicService, ctx));
|
||||
routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx));
|
||||
}
|
||||
|
||||
@@ -251,6 +257,52 @@ public final class MusicRoutes {
|
||||
return volume;
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/seek",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Seek the currently playing track to a given position",
|
||||
tags = {"Music"},
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = SeekRequest.class)),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = SeekResponse.class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void seek(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
long positionMs = seekPosition(ctx, manager);
|
||||
|
||||
manager.seek(positionMs);
|
||||
ctx.json(Map.of("status", "ok", "positionMs", positionMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates the {@code positionMs} field of the request body, failing with 400
|
||||
* when missing, negative, or past the duration of the currently playing track (when known).
|
||||
*/
|
||||
private static long seekPosition(Context ctx, MusicManager manager) {
|
||||
Object rawPosition = ctx.bodyAsClass(Map.class).get("positionMs");
|
||||
long positionMs;
|
||||
try {
|
||||
positionMs = rawPosition instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(rawPosition));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BadRequestResponse("positionMs must be a non-negative integer");
|
||||
}
|
||||
|
||||
if (positionMs < 0) {
|
||||
throw new BadRequestResponse("positionMs must be a non-negative integer");
|
||||
}
|
||||
|
||||
Track currentTrack = manager.getCurrentlyPlaying();
|
||||
if (currentTrack != null && positionMs > currentTrack.getInfo().getLength()) {
|
||||
throw new BadRequestResponse("positionMs must not exceed the duration of the currently playing track");
|
||||
}
|
||||
|
||||
return positionMs;
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/vote-skip/{userId}",
|
||||
methods = HttpMethod.POST,
|
||||
@@ -397,6 +449,32 @@ public final class MusicRoutes {
|
||||
}));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/queue/reorder",
|
||||
methods = HttpMethod.PATCH,
|
||||
summary = "Move a queued track to a different position; the currently playing track (position 1) can neither be moved nor be a destination",
|
||||
tags = {"Music"},
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = ReorderRequest.class)),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = ReorderResponse.class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void reorder(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
ReorderRequest body = ctx.bodyAsClass(ReorderRequest.class);
|
||||
|
||||
QueueActionResult result = manager.getQueueManager().reorder(body.fromPosition(), body.toPosition());
|
||||
if (result.getStatus() == QueueActionResult.Status.ERROR) {
|
||||
throw new BadRequestResponse(result.getMessage());
|
||||
}
|
||||
|
||||
announce(manager, result.getMessage());
|
||||
ctx.json(resultBodyWithQueue(result, manager));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a non-blank field from a request body, failing with 400 when missing or blank.
|
||||
*/
|
||||
@@ -511,6 +589,19 @@ public final class MusicRoutes {
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a JSON-friendly summary for a {@link QueueActionResult}, additionally including the
|
||||
* current queue listing so the caller can resync its state (e.g. after a drag-and-drop
|
||||
* reorder attempt, successful or not).
|
||||
*/
|
||||
private static Map<String, Object> resultBodyWithQueue(QueueActionResult result, MusicManager manager) {
|
||||
Map<String, Object> body = resultBody(result);
|
||||
body.put("queue", manager.getQueueManager().getQueue().stream()
|
||||
.map(MusicRoutes::trackInfoSummary)
|
||||
.toList());
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a JSON-friendly summary for the currently playing track, or {@code null} when idle.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /music/{guildId}/queue/reorder} request body.
|
||||
*
|
||||
* <p>Both positions are 1-based and refer to the queue listing returned by {@code
|
||||
* /music/{guildId}/queue} (where position 1 is the currently playing track).
|
||||
*/
|
||||
public record ReorderRequest(
|
||||
int fromPosition,
|
||||
int toPosition
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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}/queue/reorder} response, which extends the
|
||||
* standard {@link CommandResultResponse} with the current queue listing so the caller can resync
|
||||
* its state (e.g. after a drag-and-drop reorder attempt, successful or not).
|
||||
*/
|
||||
public record ReorderResponse(
|
||||
String status,
|
||||
String message,
|
||||
List<QueuedTrackResponse> queue
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /music/{guildId}/seek} request body.
|
||||
*/
|
||||
public record SeekRequest(
|
||||
long positionMs
|
||||
) {
|
||||
}
|
||||
@@ -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}/seek} response.
|
||||
*/
|
||||
public record SeekResponse(
|
||||
String status,
|
||||
long positionMs
|
||||
) {
|
||||
}
|
||||
@@ -367,6 +367,15 @@ public class MusicManager implements MusicManagerCallback {
|
||||
updatePlayer("set volume", builder -> builder.setVolume(volume));
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks the currently playing track to the given position.
|
||||
*
|
||||
* @param positionMs the position to seek to, in milliseconds
|
||||
*/
|
||||
public void seek(long positionMs) {
|
||||
updatePlayer("seek to " + positionMs, builder -> builder.setPosition(positionMs));
|
||||
}
|
||||
|
||||
public int getQueueSize() {
|
||||
return getQueueManager().getQueueSize();
|
||||
}
|
||||
|
||||
@@ -276,4 +276,42 @@ public class QueueManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the track at {@code fromIndex} to {@code toIndex} within the queue, both 1-based
|
||||
* and inclusive of the currently playing track at index 1. Reordering the currently playing
|
||||
* track (as either the source or destination position) is rejected, since it cannot be
|
||||
* repositioned while it's playing.
|
||||
*/
|
||||
public QueueActionResult reorder(int fromIndex, int toIndex) {
|
||||
synchronized (queue) {
|
||||
int size = queue.size();
|
||||
if (queue.isEmpty()) {
|
||||
return QueueActionResult.none();
|
||||
}
|
||||
|
||||
if (fromIndex < 1 || fromIndex > size || toIndex < 1 || toIndex > size) {
|
||||
return QueueActionResult.error("Position is out of bounds");
|
||||
}
|
||||
|
||||
if (fromIndex == 1 || toIndex == 1) {
|
||||
return QueueActionResult.error("Cannot reorder the currently playing track");
|
||||
}
|
||||
|
||||
if (fromIndex == toIndex) {
|
||||
return QueueActionResult.none();
|
||||
}
|
||||
|
||||
logger.debug("Guild {}: moving queue index {} to {}", guild.getId(), fromIndex - 1, toIndex - 1);
|
||||
|
||||
List<TrackInfo> newQueue = new ArrayList<>(queue);
|
||||
TrackInfo moved = newQueue.remove(fromIndex - 1);
|
||||
newQueue.add(toIndex - 1, moved);
|
||||
|
||||
queue.clear();
|
||||
queue.addAll(newQueue);
|
||||
|
||||
return QueueActionResult.success(String.format("Moved track #%d to position #%d", fromIndex, toIndex));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -250,6 +250,74 @@ public class QueueManagerTest {
|
||||
assertEquals(QueueActionResult.Status.INFO, result.getStatus());
|
||||
}
|
||||
|
||||
// --- Reorder ---
|
||||
|
||||
@Test
|
||||
public void reorderOnEmptyQueueReturnsNone() {
|
||||
QueueActionResult result = queueManager.reorder(1, 2);
|
||||
assertEquals(QueueActionResult.Status.NOACTION, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reorderOutOfBoundsReturnsError() {
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||
|
||||
QueueActionResult result = queueManager.reorder(2, 5);
|
||||
assertEquals(QueueActionResult.Status.ERROR, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reorderCurrentlyPlayingTrackAsSourceReturnsError() {
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t3"), mockMember("u1"));
|
||||
|
||||
QueueActionResult result = queueManager.reorder(1, 2);
|
||||
assertEquals(QueueActionResult.Status.ERROR, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reorderCurrentlyPlayingTrackAsDestinationReturnsError() {
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t3"), mockMember("u1"));
|
||||
|
||||
QueueActionResult result = queueManager.reorder(2, 1);
|
||||
assertEquals(QueueActionResult.Status.ERROR, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reorderSamePositionReturnsNone() {
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||
|
||||
QueueActionResult result = queueManager.reorder(2, 2);
|
||||
assertEquals(QueueActionResult.Status.NOACTION, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reorderMovesTrackToNewPosition() {
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t3"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t4"), mockMember("u1"));
|
||||
|
||||
QueueActionResult result = queueManager.reorder(4, 2);
|
||||
assertEquals(QueueActionResult.Status.SUCCESS, result.getStatus());
|
||||
|
||||
List<TrackInfo> q = queueManager.getQueue();
|
||||
assertEquals("t1", q.get(0).getTrack().getInfo().getIdentifier());
|
||||
assertEquals("t4", q.get(1).getTrack().getInfo().getIdentifier());
|
||||
assertEquals("t2", q.get(2).getTrack().getInfo().getIdentifier());
|
||||
assertEquals("t3", q.get(3).getTrack().getInfo().getIdentifier());
|
||||
}
|
||||
|
||||
// --- isEmpty / getQueueSize ---
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user