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:
anthorob committed 2026-10-03 14:06:29 -04:00
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.QueueRequest;
import me.bobert.netneutrality.http.server.dto.QueueResponse; import me.bobert.netneutrality.http.server.dto.QueueResponse;
import me.bobert.netneutrality.http.server.dto.QueuedTrackResponse; 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.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.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;
@@ -67,8 +71,10 @@ public final class MusicRoutes {
routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx));
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.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}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/queue", ctx -> play(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)); routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx));
} }
@@ -251,6 +257,52 @@ public final class MusicRoutes {
return volume; 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( @OpenApi(
path = "/music/{guildId}/vote-skip/{userId}", path = "/music/{guildId}/vote-skip/{userId}",
methods = HttpMethod.POST, 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. * 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; 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. * 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)); 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() { public int getQueueSize() {
return getQueueManager().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()); 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 --- // --- isEmpty / getQueueSize ---
@Test @Test