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 69d543a..e04bad6 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java @@ -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 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 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 resultBodyWithQueue(QueueActionResult result, MusicManager manager) { + Map 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. */ diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/ReorderRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/ReorderRequest.java new file mode 100644 index 0000000..9bbf324 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/ReorderRequest.java @@ -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. + * + *

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 +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/ReorderResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/ReorderResponse.java new file mode 100644 index 0000000..d63da67 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/ReorderResponse.java @@ -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 queue +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/SeekRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/SeekRequest.java new file mode 100644 index 0000000..ced32ba --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/SeekRequest.java @@ -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 +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/SeekResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/SeekResponse.java new file mode 100644 index 0000000..1810865 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/SeekResponse.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}/seek} response. + */ +public record SeekResponse( + String status, + long positionMs +) { +} diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java index 388216c..56392e4 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java @@ -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(); } diff --git a/src/main/java/me/bobert/netneutrality/music/QueueManager.java b/src/main/java/me/bobert/netneutrality/music/QueueManager.java index 8585cc5..ac3b48d 100644 --- a/src/main/java/me/bobert/netneutrality/music/QueueManager.java +++ b/src/main/java/me/bobert/netneutrality/music/QueueManager.java @@ -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 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)); + } + } + } diff --git a/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java b/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java index d7de999..6e5275f 100644 --- a/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java +++ b/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java @@ -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 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