From 142cc77ec8e400f99571c915ba10e973131373c7 Mon Sep 17 00:00:00 2001 From: anthorob Date: Wed, 28 Oct 2026 16:34:38 -0400 Subject: [PATCH] **Add playlist metadata support to queue and events** Enhanced queueing functionality to include playlist metadata, such as name, identifier, and track count. Updated event payloads to handle playlist information and introduced a new `playlist_added` event for streamlined notifications. Adjusted tests and documentation to reflect these changes. --- docs/events.md | 4 +- .../http/server/MusicRoutes.java | 19 +++++- .../music/PendingSearchResults.java | 24 +++++++- .../netneutrality/music/PlaylistInfo.java | 16 +++++ .../netneutrality/music/QueueManager.java | 61 ++++++++++++++++++- .../music/SearchResultHandler.java | 3 +- .../bobert/netneutrality/music/TrackInfo.java | 11 ++++ .../tasks/music/LoadPlaylistTask.java | 6 +- .../music/QueueManagerEventsTest.java | 42 ++++++++++++- 9 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/music/PlaylistInfo.java diff --git a/docs/events.md b/docs/events.md index 12491fa..618fe51 100644 --- a/docs/events.md +++ b/docs/events.md @@ -98,13 +98,13 @@ Key points: | Type | Payload | |---|---| -| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `uri`, `requesterId`. `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). | +| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`playlist_added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `author`, `duration_ms`, `uri`, `artwork_url`, `requester_id` and `playlist` (`{name, identifier, track_count}` or `null` when the track was not queued from a playlist). `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). `playlist_added` is emitted once per playlist load (instead of one `added` event per track), lists only the tracks actually added (duplicates are skipped) and carries a top-level `playlist` object (`name`, `identifier`, `track_count`, the number actually added). | | `QUEUE_SHUFFLED` | `size` | | `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` | | `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` | | `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. | -`size` in `QUEUE_UPDATED` is unchanged, so existing consumers keep working. +Consumers must handle the `playlist_added` change value and the per-item `playlist` field. The REST queue listing (`GET /music/{guildId}/queue`) also reports `playlist` on every entry. ## Extending the event system 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 ce7ea7a..2bb05be 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java @@ -41,6 +41,7 @@ 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.PlaylistInfo; import me.bobert.netneutrality.music.QueueActionResult; import me.bobert.netneutrality.music.SourceFinder; import me.bobert.netneutrality.music.TrackInfo; @@ -490,10 +491,12 @@ public final class MusicRoutes { throw new BadRequestResponse("index is required when searchId is provided unless queueAll is true"); } - List candidates = PendingSearchResults.consume(searchId, guild.getId()); - if (candidates == null) { + PendingSearchResults.Consumed consumed = PendingSearchResults.consumeWithPlaylist(searchId, guild.getId()); + if (consumed == null) { throw new NotFoundResponse("Unknown or expired searchId"); } + List candidates = consumed.candidates(); + PlaylistInfo playlist = consumed.playlist(); if (!queueAll && (index < 0 || index >= candidates.size())) { throw new BadRequestResponse("index is out of range"); @@ -506,7 +509,7 @@ public final class MusicRoutes { if (queueAll) { ctx.future(() -> resolveMember(guild, userId) .thenApply(member -> { - candidates.forEach(candidate -> manager.getQueueManager().queue(candidate, member)); + manager.getQueueManager().queue(candidates, member, playlist); announce(manager, String.format("Queued %d track%s requested by %s", candidates.size(), candidates.size() > 1 ? "s" : "", member.getEffectiveName())); ctx.json(queuedAllBody(candidates, manager.getQueueSize())); @@ -780,6 +783,16 @@ public final class MusicRoutes { Map summary = trackSummary(info.getTrack()); summary.put("requester_id", info.getRequester().getId()); summary.put("skip_count", info.getSkipCount()); + PlaylistInfo playlist = info.getPlaylist(); + if (playlist == null) { + summary.put("playlist", null); + } else { + Map playlistMap = new LinkedHashMap<>(); + playlistMap.put("name", playlist.name()); + playlistMap.put("identifier", playlist.identifier()); + playlistMap.put("track_count", playlist.trackCount()); + summary.put("playlist", playlistMap); + } return summary; } } diff --git a/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java b/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java index 2edfd5f..5bc876b 100644 --- a/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java +++ b/src/main/java/me/bobert/netneutrality/music/PendingSearchResults.java @@ -46,10 +46,17 @@ public final class PendingSearchResults { * @return the generated search id */ public static String put(String guildId, List candidates) { + return put(guildId, candidates, null); + } + + /** + * Stores the given candidates together with the playlist they came from (may be null). + */ + public static String put(String guildId, List candidates, PlaylistInfo playlist) { sweep(); String searchId = UUID.randomUUID().toString(); - entries.put(searchId, new Entry(guildId, candidates, Instant.now().plus(TTL))); + entries.put(searchId, new Entry(guildId, candidates, playlist, Instant.now().plus(TTL))); return searchId; } @@ -62,6 +69,14 @@ public final class PendingSearchResults { * belongs to a different guild */ public static List consume(String searchId, String guildId) { + Consumed consumed = consumeWithPlaylist(searchId, guildId); + return consumed == null ? null : consumed.candidates(); + } + + /** + * Like {@link #consume(String, String)} but also returns the originating playlist, if any. + */ + public static Consumed consumeWithPlaylist(String searchId, String guildId) { sweep(); Entry entry = entries.remove(searchId); @@ -69,7 +84,10 @@ public final class PendingSearchResults { return null; } - return entry.candidates(); + return new Consumed(entry.candidates(), entry.playlist()); + } + + public record Consumed(List candidates, PlaylistInfo playlist) { } private static void sweep() { @@ -77,6 +95,6 @@ public final class PendingSearchResults { entries.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiresAt())); } - private record Entry(String guildId, List candidates, Instant expiresAt) { + private record Entry(String guildId, List candidates, PlaylistInfo playlist, Instant expiresAt) { } } diff --git a/src/main/java/me/bobert/netneutrality/music/PlaylistInfo.java b/src/main/java/me/bobert/netneutrality/music/PlaylistInfo.java new file mode 100644 index 0000000..ee31c9d --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/music/PlaylistInfo.java @@ -0,0 +1,16 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * Describes the playlist a queued track originated from. + * + * @param identifier the playlist identifier, or null when unknown + * @param name the playlist display name + * @param trackCount number of tracks in the playlist + */ +public record PlaylistInfo(String identifier, String name, int trackCount) { +} diff --git a/src/main/java/me/bobert/netneutrality/music/QueueManager.java b/src/main/java/me/bobert/netneutrality/music/QueueManager.java index 232e343..4ad3e5f 100644 --- a/src/main/java/me/bobert/netneutrality/music/QueueManager.java +++ b/src/main/java/me/bobert/netneutrality/music/QueueManager.java @@ -79,10 +79,26 @@ public class QueueManager { item.put("uri", info.getTrack().getInfo().getUri()); item.put("artwork_url", info.getTrack().getInfo().getArtworkUrl()); item.put("requester_id", info.getRequester() == null ? null : info.getRequester().getId()); + item.put("playlist", playlistMap(info.getPlaylist())); return item; } + private static Map playlistMap(PlaylistInfo playlist) { + if (playlist == null) { + return null; + } + Map map = new HashMap<>(); + map.put("name", playlist.name()); + map.put("identifier", playlist.identifier()); + map.put("track_count", playlist.trackCount()); + return map; + } + private void notifyQueueUpdated(String change, List> items) { + notifyQueueUpdated(change, items, null); + } + + private void notifyQueueUpdated(String change, List> items, PlaylistInfo playlist) { MusicManager musicManager = scheduler.getMusicManager(); if (musicManager == null || musicManager.getEvents() == null) { return; @@ -91,6 +107,9 @@ public class QueueManager { payload.put("size", queue.size()); payload.put("change", change); payload.put("items", items); + if (playlist != null) { + payload.put("playlist", playlistMap(playlist)); + } musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), payload)); } @@ -155,7 +174,47 @@ public class QueueManager { } public void queue(List tracks, Member member) { - tracks.forEach(track -> queue(track, member)); + queue(tracks, member, null); + } + + public void queue(List tracks, Member member, PlaylistInfo playlist) { + if (playlist == null) { + tracks.forEach(track -> queue(track, member)); + return; + } + + synchronized (queue) { + boolean idle = scheduler.getCurrentTrack() == null; + List added = new ArrayList<>(); + + for (Track track : tracks) { + TrackInfo info = new TrackInfo(track, member, playlist); + if (queue.contains(info)) { + continue; + } + queue.add(info); + added.add(info); + } + + if (added.isEmpty()) { + return; + } + + PlaylistInfo actual = new PlaylistInfo(playlist.identifier(), playlist.name(), added.size()); + List all = new ArrayList<>(queue); + List> items = new ArrayList<>(); + for (TrackInfo info : added) { + items.add(item(all.indexOf(info), info)); + } + notifyQueueUpdated("playlist_added", items, actual); + + if (idle) { + TrackInfo head = queue.peek(); + if (head != null) { + startTrack(head.getTrack(), true); + } + } + } } public void queue(Track track, Member member) { diff --git a/src/main/java/me/bobert/netneutrality/music/SearchResultHandler.java b/src/main/java/me/bobert/netneutrality/music/SearchResultHandler.java index d77d21f..0508a27 100644 --- a/src/main/java/me/bobert/netneutrality/music/SearchResultHandler.java +++ b/src/main/java/me/bobert/netneutrality/music/SearchResultHandler.java @@ -65,7 +65,8 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler { boolean truncated = tracks.size() > maxPlaylistSize; List candidates = truncated ? tracks.subList(0, maxPlaylistSize) : tracks; - String searchId = PendingSearchResults.put(guild.getId(), candidates); + String searchId = PendingSearchResults.put(guild.getId(), candidates, + new PlaylistInfo(identifier, result.getInfo().getName(), candidates.size())); future.complete(new PlayOutcome.PendingPlaylist(searchId, identifier, result.getInfo().getName(), candidates, truncated)); } diff --git a/src/main/java/me/bobert/netneutrality/music/TrackInfo.java b/src/main/java/me/bobert/netneutrality/music/TrackInfo.java index 16ed8bf..281423d 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackInfo.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackInfo.java @@ -21,12 +21,23 @@ public class TrackInfo { private final Set skips; private static final Logger logger = LogManager.getLogger(TrackInfo.class); + private final PlaylistInfo playlist; + public TrackInfo(Track track, Member member) { + this(track, member, null); + } + + public TrackInfo(Track track, Member member, PlaylistInfo playlist) { this.track = track; this.member = member; + this.playlist = playlist; this.skips = new HashSet<>(); } + public PlaylistInfo getPlaylist() { + return playlist; + } + public Track getTrack() { return track; } diff --git a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java index 52e8e3a..5c3c10e 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java @@ -13,6 +13,7 @@ import me.bobert.netneutrality.core.tasks.trait.Deletable; import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.music.MusicManager; +import me.bobert.netneutrality.music.PlaylistInfo; import me.bobert.netneutrality.music.SourceFinder; import me.bobert.netneutrality.core.tasks.generic.AcceptButton; import me.bobert.netneutrality.core.tasks.generic.CancelButton; @@ -93,7 +94,8 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta // Load playlist normally if (MusicUtils.connect(context, musicManager)) { - musicManager.getQueueManager().queue(tracks, context.getGuildUser()); + musicManager.getQueueManager().queue(tracks, context.getGuildUser(), + new PlaylistInfo(null, playlistName, tracks.size())); context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, context.getGuildUser(), sourceType)); } @@ -104,7 +106,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta Member member = context.getGuildUser(); if (MusicUtils.connect(context, musicManager)) { - musicManager.getQueueManager().queue(tracks, member); + musicManager.getQueueManager().queue(tracks, member, new PlaylistInfo(null, playlistName, tracks.size())); context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube)); return Lifecycle.REMOVE_SELF_AND_CHILDREN; diff --git a/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java b/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java index dc47221..57e662e 100644 --- a/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java +++ b/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java @@ -78,7 +78,7 @@ public class QueueManagerEventsTest { assertEquals("added", payload(event).get("change")); assertEquals(0, items(event).get(0).get("index")); assertEquals("t1", items(event).get(0).get("identifier")); - assertEquals("u1", items(event).get(0).get("requesterId")); + assertEquals("u1", items(event).get(0).get("requester_id")); } @Test @@ -134,6 +134,46 @@ public class QueueManagerEventsTest { assertEquals("guild1", events.get(1).guildId()); } + @Test + public void playlistPublishesSingleEventWithPlaylist() { + List tracks = List.of(QueueManagerTest.mockTrack("t1"), QueueManagerTest.mockTrack("t2"), + QueueManagerTest.mockTrack("t3")); + + queueManager.queue(tracks, member, new PlaylistInfo("pl1", "My List", 3)); + + assertEquals(1, events.size()); + Map payload = payload(events.get(0)); + assertEquals("playlist_added", payload.get("change")); + assertEquals(3, payload.get("size")); + assertEquals(3, items(events.get(0)).size()); + @SuppressWarnings("unchecked") + Map playlist = (Map) payload.get("playlist"); + assertEquals("My List", playlist.get("name")); + assertEquals("pl1", playlist.get("identifier")); + assertEquals(3, playlist.get("track_count")); + assertNotNull(items(events.get(0)).get(0).get("playlist")); + } + + @Test + public void playlistSkipsDuplicates() { + fill(1); + List tracks = List.of(QueueManagerTest.mockTrack("t1"), QueueManagerTest.mockTrack("t2")); + + queueManager.queue(tracks, member, new PlaylistInfo(null, "L", 2)); + + assertEquals(1, events.size()); + assertEquals(1, items(events.get(0)).size()); + assertEquals("t2", items(events.get(0)).get(0).get("identifier")); + } + + @Test + public void singleTrackHasNullPlaylist() { + queueManager.queue(QueueManagerTest.mockTrack("t1"), member); + + assertNull(items(events.get(0)).get(0).get("playlist")); + assertFalse(payload(events.get(0)).containsKey("playlist")); + } + @Test public void removeHeadPublishesRemovedAtIndexZero() { fill(2);