**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.
This commit is contained in:
anthorob committed 2026-10-28 16:34:38 -04:00
1 parent d376e245b7
commit 142cc77ec8
9 files changed
+172 -12

No files matched your search

+2 -2
View File
@@ -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
@@ -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<Track> 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<Track> 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<String, Object> 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<String, Object> 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;
}
}
@@ -46,10 +46,17 @@ public final class PendingSearchResults {
* @return the generated search id
*/
public static String put(String guildId, List<Track> 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<Track> 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<Track> 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<Track> 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<Track> candidates, Instant expiresAt) {
private record Entry(String guildId, List<Track> candidates, PlaylistInfo playlist, Instant expiresAt) {
}
}
@@ -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) {
}
@@ -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<String, Object> playlistMap(PlaylistInfo playlist) {
if (playlist == null) {
return null;
}
Map<String, Object> 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<Map<String, Object>> items) {
notifyQueueUpdated(change, items, null);
}
private void notifyQueueUpdated(String change, List<Map<String, Object>> 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<Track> tracks, Member member) {
queue(tracks, member, null);
}
public void queue(List<Track> tracks, Member member, PlaylistInfo playlist) {
if (playlist == null) {
tracks.forEach(track -> queue(track, member));
return;
}
synchronized (queue) {
boolean idle = scheduler.getCurrentTrack() == null;
List<TrackInfo> 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<TrackInfo> all = new ArrayList<>(queue);
List<Map<String, Object>> 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) {
@@ -65,7 +65,8 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
boolean truncated = tracks.size() > maxPlaylistSize;
List<Track> 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));
}
@@ -21,12 +21,23 @@ public class TrackInfo {
private final Set<String> 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;
}
@@ -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;
@@ -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<Track> 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<String, Object> 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<String, Object> playlist = (Map<String, Object>) 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<Track> 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);