**Improve event handling with new music and queue updates**

Added new `MusicEvent` types, including `TRACK_ERROR`, `TRACK_STUCK`, and `PLAYER_POSITION`, providing enhanced diagnostics and feedback. Improved queue mutation notifications by introducing granular events like `added`, `removed`, `moved`, `cleared`, and `shuffled`. Updated tests and documentation to reflect the changes, ensuring consistency and clarity across the event system.
This commit is contained in:
anthorob committed 2026-10-28 03:58:19 -04:00
1 parent 75b495b08c
commit 87ba09b001
9 files changed
+336 -20

No files matched your search

@@ -27,7 +27,11 @@ public record MusicEvent(Type type, String guildId, long timestamp, long seq, Ob
PLAYER_SEEKED,
VOTE_SKIP_UPDATED,
VOICE_CONNECTED,
VOICE_DISCONNECTED
VOICE_DISCONNECTED,
QUEUE_SHUFFLED,
TRACK_ERROR,
TRACK_STUCK,
PLAYER_POSITION
}
/**
@@ -171,6 +171,8 @@ public class EventsHandler implements EventListener {
return Map.of(
"channelId", channelId,
"userId", event.getEntity().getId(),
"username", event.getEntity().getUser().getName(),
"nickname", event.getEntity().getEffectiveName(),
"bot", event.getEntity().getUser().isBot(),
"self", self);
}
@@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.Helpers;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.NodeOptions;
import dev.arbjerg.lavalink.client.event.PlayerUpdateEvent;
import dev.arbjerg.lavalink.client.event.ReadyEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
@@ -209,6 +210,11 @@ public class MusicServiceImpl implements MusicService {
subscriptions.add(client.on(TrackStuckEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
subscriptions.add(client.on(PlayerUpdateEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onPlayerUpdate(
event.getState().getTime(), event.getState().getPosition(),
event.getState().getConnected(), event.getState().getPing()))));
subscriptions.add(client.on(WebSocketClosedEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
}
@@ -15,6 +15,7 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingDeque;
@@ -63,18 +64,31 @@ public class QueueManager {
public void removeHeadOfQueue() {
synchronized (queue) {
queue.poll();
notifyQueueUpdated();
TrackInfo head = queue.poll();
notifyQueueUpdated("removed", head == null ? List.of() : List.of(item(0, head)));
}
}
private void notifyQueueUpdated() {
private static Map<String, Object> item(int index, TrackInfo info) {
Map<String, Object> item = new HashMap<>();
item.put("index", index);
item.put("identifier", info.getTrack().getInfo().getIdentifier());
item.put("title", info.getTrack().getInfo().getTitle());
item.put("uri", info.getTrack().getInfo().getUri());
item.put("requesterId", info.getRequester() == null ? null : info.getRequester().getId());
return item;
}
private void notifyQueueUpdated(String change, List<Map<String, Object>> items) {
MusicManager musicManager = scheduler.getMusicManager();
if (musicManager == null || musicManager.getEvents() == null) {
return;
}
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(),
Map.of("size", queue.size())));
Map<String, Object> payload = new HashMap<>();
payload.put("size", queue.size());
payload.put("change", change);
payload.put("items", items);
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), payload));
}
public boolean isEmpty() {
@@ -91,7 +105,7 @@ public class QueueManager {
}
queue.clear();
notifyQueueUpdated();
notifyQueueUpdated("cleared", List.of());
scheduler.getCallback().onQueueEmpty();
return QueueActionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : "")));
}
@@ -109,8 +123,16 @@ public class QueueManager {
}
TrackInfo first = queue.peek();
List<Map<String, Object>> removed = new ArrayList<>();
int position = 0;
for (TrackInfo trackInfo : queue) {
if (member.equals(trackInfo.getRequester())) {
removed.add(item(position, trackInfo));
}
position++;
}
queue.removeIf(trackInfo -> member.equals(trackInfo.getRequester()));
notifyQueueUpdated();
notifyQueueUpdated("removed", removed);
if (first != null && member.equals(first.getRequester())) {
skip(1);
@@ -148,7 +170,7 @@ public class QueueManager {
} else {
queue.add(info);
}
notifyQueueUpdated();
notifyQueueUpdated("added", List.of(item(queue.peekFirst() == info ? 0 : queue.size() - 1, info)));
if (idle) {
TrackInfo head = queue.peek();
@@ -196,8 +218,8 @@ public class QueueManager {
public void startNextTrack() {
synchronized (queue) {
queue.poll();
notifyQueueUpdated();
TrackInfo polled = queue.poll();
notifyQueueUpdated("removed", polled == null ? List.of() : List.of(item(0, polled)));
TrackInfo next = queue.peek();
if (next != null) {
@@ -259,7 +281,12 @@ public class QueueManager {
queue.clear();
queue.add(current);
queue.addAll(newQueue);
notifyQueueUpdated();
notifyQueueUpdated("shuffled", List.of());
MusicManager musicManager = scheduler.getMusicManager();
if (musicManager != null && musicManager.getEvents() != null) {
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_SHUFFLED, guild.getId(),
Map.of("size", queue.size())));
}
return QueueActionResult.success("The queue is now shuffled.");
}
@@ -279,11 +306,11 @@ public class QueueManager {
if (index > 1) {
List<TrackInfo> newQueue = new ArrayList<>(queue);
newQueue.remove(index - 1);
TrackInfo removedInfo = newQueue.remove(index - 1);
queue.clear();
queue.addAll(newQueue);
notifyQueueUpdated();
notifyQueueUpdated("removed", List.of(item(index - 1, removedInfo)));
return QueueActionResult.success(String.format("Removed track #%d from the queue", index));
}
@@ -327,7 +354,10 @@ public class QueueManager {
queue.clear();
queue.addAll(newQueue);
notifyQueueUpdated();
Map<String, Object> movedItem = item(toIndex - 1, moved);
movedItem.put("fromIndex", fromIndex - 1);
movedItem.put("toIndex", toIndex - 1);
notifyQueueUpdated("moved", List.of(movedItem));
return QueueActionResult.success(String.format("Moved track #%d to position #%d", fromIndex, toIndex));
}
@@ -33,6 +33,8 @@ public class TrackScheduler {
private volatile Context context;
private volatile Track currentTrack;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
private static final long POSITION_THROTTLE_MS = 5000;
private volatile long lastPositionSent = Long.MIN_VALUE;
public TrackScheduler(MusicManager manager) {
this.manager = manager;
@@ -137,10 +139,7 @@ public class TrackScheduler {
// clone of this back to your queue
}
private void publish(MusicEvent.Type type, Track track, String reason) {
if (manager.getEvents() == null) {
return;
}
private static java.util.Map<String, Object> trackPayload(Track track) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("identifier", track.getInfo().getIdentifier());
payload.put("title", track.getInfo().getTitle());
@@ -148,6 +147,14 @@ public class TrackScheduler {
payload.put("length", track.getInfo().getLength());
payload.put("uri", track.getInfo().getUri());
payload.put("artworkUrl", track.getInfo().getArtworkUrl());
return payload;
}
private void publish(MusicEvent.Type type, Track track, String reason) {
if (manager.getEvents() == null) {
return;
}
java.util.Map<String, Object> payload = trackPayload(track);
if (reason != null) {
payload.put("reason", reason);
}
@@ -157,6 +164,14 @@ public class TrackScheduler {
public void onTrackException(Track track, TrackException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
if (manager.getEvents() != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track));
payload.put("message", exception.getMessage());
payload.put("severity", exception.getSeverity() == null ? null : exception.getSeverity().name());
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
}
LogHandler.log(new ContextException(
String.format("Error while playing track: %s", track.getInfo().getTitle()),
getContext(),
@@ -166,9 +181,36 @@ public class TrackScheduler {
public void onTrackStuck(Track track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
if (manager.getEvents() != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track));
payload.put("thresholdMs", thresholdMs);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_STUCK, guild.getId(), payload));
}
stuckTrackRecoveryHandler.handle(track, context);
}
public void onPlayerUpdate(long timestamp, long position, boolean connected, long ping) {
onPlayerUpdate(timestamp, position, connected, ping, System.currentTimeMillis());
}
void onPlayerUpdate(long timestamp, long position, boolean connected, long ping, long now) {
if (manager.getEvents() == null || currentTrack == null) {
return;
}
if (lastPositionSent != Long.MIN_VALUE && now - lastPositionSent < POSITION_THROTTLE_MS) {
return;
}
lastPositionSent = now;
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("position", position);
payload.put("timestamp", timestamp);
payload.put("connected", connected);
payload.put("ping", ping);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.PLAYER_POSITION, guild.getId(), payload));
}
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
currentTrack = null;
@@ -0,0 +1,147 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.MusicEvent;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class QueueManagerEventsTest {
private final List<MusicEvent> events = new ArrayList<>();
private TrackScheduler scheduler;
private QueueManager queueManager;
private Member member;
@BeforeEach
void setUp() {
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
scheduler = mock(TrackScheduler.class);
MusicManager musicManager = mock(MusicManager.class);
EventPublisher publisher = events::add;
when(scheduler.getGuild()).thenReturn(guild);
when(scheduler.getMusicManager()).thenReturn(musicManager);
when(scheduler.getCallback()).thenReturn(mock(MusicManagerCallback.class));
when(musicManager.getEvents()).thenReturn(publisher);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(QueueManagerTest.mockTrack("t1"));
member = mock(Member.class);
when(member.getId()).thenReturn("u1");
queueManager = new QueueManager(scheduler);
}
private void fill(int count) {
for (int i = 1; i <= count; i++) {
Track track = QueueManagerTest.mockTrack("t" + i);
queueManager.queue(track, member);
}
events.clear();
}
@SuppressWarnings("unchecked")
private Map<String, Object> payload(MusicEvent event) {
return (Map<String, Object>) event.payload();
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> items(MusicEvent event) {
return (List<Map<String, Object>>) payload(event).get("items");
}
@Test
public void addPublishesAddedWithIndex() {
queueManager.queue(QueueManagerTest.mockTrack("t1"), member);
assertEquals(1, events.size());
MusicEvent event = events.get(0);
assertEquals(MusicEvent.Type.QUEUE_UPDATED, event.type());
assertEquals("guild1", event.guildId());
assertEquals(1, payload(event).get("size"));
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"));
}
@Test
public void removePublishesRemovedWithOriginalIndex() {
fill(3);
queueManager.removeFromQueue(3);
assertEquals(1, events.size());
assertEquals("removed", payload(events.get(0)).get("change"));
assertEquals(2, payload(events.get(0)).get("size"));
assertEquals(2, items(events.get(0)).get(0).get("index"));
assertEquals("t3", items(events.get(0)).get(0).get("identifier"));
}
@Test
public void movePublishesFromAndTo() {
fill(4);
queueManager.reorder(4, 2);
assertEquals(1, events.size());
assertEquals("moved", payload(events.get(0)).get("change"));
Map<String, Object> item = items(events.get(0)).get(0);
assertEquals(3, item.get("fromIndex"));
assertEquals(1, item.get("toIndex"));
assertEquals("t4", item.get("identifier"));
}
@Test
public void clearPublishesCleared() {
fill(2);
queueManager.clearQueue();
assertEquals(1, events.size());
assertEquals("cleared", payload(events.get(0)).get("change"));
assertEquals(0, payload(events.get(0)).get("size"));
assertTrue(items(events.get(0)).isEmpty());
}
@Test
public void shufflePublishesShuffledAndQueueShuffled() {
fill(4);
queueManager.shuffle();
assertEquals(2, events.size());
assertEquals(MusicEvent.Type.QUEUE_UPDATED, events.get(0).type());
assertEquals("shuffled", payload(events.get(0)).get("change"));
assertEquals(4, payload(events.get(0)).get("size"));
assertEquals(MusicEvent.Type.QUEUE_SHUFFLED, events.get(1).type());
assertEquals("guild1", events.get(1).guildId());
}
@Test
public void removeHeadPublishesRemovedAtIndexZero() {
fill(2);
queueManager.removeHeadOfQueue();
assertEquals("removed", payload(events.get(0)).get("change"));
assertEquals(0, items(events.get(0)).get(0).get("index"));
assertEquals(1, payload(events.get(0)).get("size"));
}
}
@@ -11,6 +11,7 @@ import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.*;
@@ -76,6 +77,75 @@ public class TrackSchedulerTest {
assertNull(scheduler.getCurrentTrack());
}
@Test
@SuppressWarnings("unchecked")
public void onTrackExceptionPublishesTrackError() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
dev.arbjerg.lavalink.client.player.TrackException exception = mock(dev.arbjerg.lavalink.client.player.TrackException.class);
when(exception.getMessage()).thenReturn("boom");
scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class));
scheduler.onTrackException(QueueManagerTest.mockTrack("t1"), exception);
assertEquals(1, events.size());
assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.TRACK_ERROR, events.get(0).type());
assertEquals("guild1", events.get(0).guildId());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) events.get(0).payload();
assertEquals("boom", payload.get("message"));
assertEquals("t1", ((java.util.Map<String, Object>) payload.get("track")).get("identifier"));
}
@Test
@SuppressWarnings("unchecked")
public void onTrackStuckPublishesTrackStuck() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class));
scheduler.onTrackStuck(QueueManagerTest.mockTrack("t1"), 10000L);
me.bobert.netneutrality.events.MusicEvent event = events.stream()
.filter(e -> e.type() == me.bobert.netneutrality.events.MusicEvent.Type.TRACK_STUCK)
.findFirst().orElseThrow();
assertEquals("guild1", event.guildId());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) event.payload();
assertEquals(10000L, payload.get("thresholdMs"));
assertEquals("t1", ((java.util.Map<String, Object>) payload.get("track")).get("identifier"));
}
@Test
@SuppressWarnings("unchecked")
public void onPlayerUpdateIsThrottledPerGuild() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.onTrackStart(QueueManagerTest.mockTrack("t1"));
events.clear();
scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L);
scheduler.onPlayerUpdate(101L, 1600L, true, 20L, 3000L);
scheduler.onPlayerUpdate(102L, 6500L, true, 20L, 6000L);
assertEquals(2, events.size());
assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.PLAYER_POSITION, events.get(0).type());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) events.get(0).payload();
assertEquals(1500L, payload.get("position"));
assertEquals(100L, payload.get("timestamp"));
assertEquals(true, payload.get("connected"));
assertEquals(20L, payload.get("ping"));
}
@Test
public void onPlayerUpdateSkippedWhenNothingPlaying() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L);
assertEquals(0, events.size());
}
@Test
public void getDjReturnsNullWhenNoContext() {
assertNull(scheduler.getDj());