**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:
1 parent
d376e245b7
commit
142cc77ec8
9 files changed
+172
-12
No files matched your search
+2
-2
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user