fix(music): playlist tracks replaced each other instead of queueing

- TrackScheduler: track current track locally instead of relying on
  async-cached LavalinkPlayer.getTrack(); playlist enqueues now queue
  instead of REPLACED-storming (only last track was audible)
- TrackEndHandler: ignore REPLACED/STOPPED (intentional), advance on
  FINISHED/LOAD_FAILED via nextTrack(), log CLEANUP
- TrackStarter: preserve player volume between tracks instead of
  hardcoding 100; report final failure to scheduler
- skip(): start next track atomically (single player update), support
  skipping several tracks; prefix skip N no longer stops playback
- QueueStore: restore queue through scheduler.enqueue
- TrackScheduler ops synchronized (queue mutated from reactor + JDA
  threads)
- syncCurrent on TrackStartEvent as authoritative source
This commit is contained in:
loki5512344 2026-08-30 00:03:13 +02:00
parent a7f7c1019e
commit 5e83356016
Signed by: boba
GPG key ID: 253067914055423B
7 changed files with 172 additions and 86 deletions

View file

@ -52,10 +52,7 @@ final class SkipCommand implements Command {
} }
} }
String skipped = manager.getScheduler().getCurrentTrack().getInfo().getTitle(); String skipped = manager.getScheduler().getCurrentTrack().getInfo().getTitle();
for (int i = 0; i < amount - 1; i++) { manager.getScheduler().skip(amount);
manager.getScheduler().nextTrack();
}
manager.getScheduler().skip();
context context
.getChannel() .getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Пропущено", "**" + skipped + "**")) .sendMessageEmbeds(EmbedTemplates.info("Пропущено", "**" + skipped + "**"))

View file

@ -51,6 +51,7 @@ class LavalinkEventRouter {
event -> { event -> {
GuildMusicManager manager = musicManagers.get(event.getGuildId()); GuildMusicManager manager = musicManagers.get(event.getGuildId());
if (manager != null) { if (manager != null) {
manager.getScheduler().syncCurrent(event.getTrack());
manager.cancelAutoLeaveTimer(); manager.cancelAutoLeaveTimer();
} }
}); });

View file

@ -61,14 +61,8 @@ class QueueStore {
if (decoded.isEmpty()) { if (decoded.isEmpty()) {
return; return;
} }
var q = manager.getScheduler().queue; for (var track : decoded) {
q.addAll(decoded.subList(1, decoded.size())); manager.getScheduler().enqueue(track);
var first = decoded.get(0);
var player = manager.getPlayer().orElse(null);
if (player != null && player.getTrack() == null) {
manager.getScheduler().startTrack(first);
} else {
q.offer(first);
} }
LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков"); LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков");
queueRepository.deleteQueue(String.valueOf(manager.getGuildId())); queueRepository.deleteQueue(String.valueOf(manager.getGuildId()));

View file

@ -18,38 +18,33 @@ class TrackEndHandler {
} }
void onTrackEnd(final Track lastTrack, final AudioTrackEndReason endReason) { void onTrackEnd(final Track lastTrack, final AudioTrackEndReason endReason) {
if (endReason == AudioTrackEndReason.LOAD_FAILED) { switch (endReason) {
handleLoadFailed(); case LOAD_FAILED -> handleLoadFailed();
} else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) { case FINISHED -> handleFinished(lastTrack);
handleUnexpectedEnd(endReason); case REPLACED, STOPPED -> {
} else if (scheduler.isRepeating() && endReason == AudioTrackEndReason.FINISHED) { // Track was intentionally replaced or stopped by skip()/stop(); the scheduler
handleFinished(lastTrack); // already advanced playback, so there is nothing to do here.
} else { }
nextOrAutoLeave(); case CLEANUP -> handleCleanedUp();
} }
} }
private void handleLoadFailed() { private void handleLoadFailed() {
LoggerDecorator.warn(log, "Load failed, skipping to next"); LoggerDecorator.warn(log, "Load failed, skipping to next");
scheduler.notifyLoadFailed(); scheduler.notifyLoadFailed();
nextOrAutoLeave(); scheduler.nextTrack();
}
private void handleUnexpectedEnd(final AudioTrackEndReason endReason) {
LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason);
manager.startAutoLeaveTimer();
} }
private void handleFinished(final Track lastTrack) { private void handleFinished(final Track lastTrack) {
scheduler.startTrack(lastTrack.makeClone()); if (scheduler.isRepeating()) {
} scheduler.startTrack(lastTrack.makeClone());
private void nextOrAutoLeave() {
var next = scheduler.pollNext();
if (next != null) {
scheduler.startTrack(next);
} else { } else {
manager.startAutoLeaveTimer(); scheduler.nextTrack();
} }
} }
private void handleCleanedUp() {
LoggerDecorator.warn(log, "Player cleaned up (node restart?), stopping session");
manager.startAutoLeaveTimer();
}
} }

View file

@ -8,7 +8,6 @@ import java.util.Queue;
import java.util.function.Consumer; import java.util.function.Consumer;
import creator.loki.feature.music.manage.filter.FilterState; import creator.loki.feature.music.manage.filter.FilterState;
import dev.arbjerg.lavalink.client.player.LavalinkPlayer;
import dev.arbjerg.lavalink.client.player.Track; import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason; import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -22,6 +21,7 @@ public class TrackScheduler {
private final TrackStarter trackStarter; private final TrackStarter trackStarter;
private final TrackEndHandler trackEndHandler; private final TrackEndHandler trackEndHandler;
final Queue<Track> queue = new LinkedList<>(); final Queue<Track> queue = new LinkedList<>();
private Track current;
private boolean repeating; private boolean repeating;
public final FilterState filterState = new FilterState(); public final FilterState filterState = new FilterState();
private Consumer<String> onLoadFailedNotify; private Consumer<String> onLoadFailedNotify;
@ -38,49 +38,78 @@ public class TrackScheduler {
public void enqueue(final Track track) { public void enqueue(final Track track) {
manager.cancelAutoLeaveTimer(); manager.cancelAutoLeaveTimer();
if (manager.getPlayer().filter(p -> p.getTrack() != null).isPresent()) { synchronized (this) {
queue.offer(track); queue.offer(track);
manager.saveQueueDebounced(); if (current == null) {
} else { nextTrack();
startTrack(track); } else {
manager.saveQueueDebounced();
}
} }
} }
public void skip() { public void skip() {
manager.getPlayer().ifPresent(p -> p.setTrack(null).subscribe(null, LOG_LAV)); skip(1);
}
public void skip(final int amount) {
synchronized (this) {
for (int i = 0; i < amount - 1 && !queue.isEmpty(); i++) {
queue.poll();
}
var next = queue.poll();
if (next != null) {
startTrack(next);
} else {
stopPlayback();
manager.startAutoLeaveTimer();
}
manager.saveQueueDebounced();
}
} }
public boolean nextTrack() { public boolean nextTrack() {
var next = queue.poll(); synchronized (this) {
if (next == null) { var next = queue.poll();
return false; if (next == null) {
current = null;
manager.startAutoLeaveTimer();
return false;
}
startTrack(next);
manager.saveQueueDebounced();
return true;
} }
startTrack(next);
manager.saveQueueDebounced();
return true;
} }
public void stop() { public void stop() {
manager.cancelAutoLeaveTimer(); synchronized (this) {
queue.clear(); manager.cancelAutoLeaveTimer();
manager.getPlayer().ifPresent(p -> p.setPaused(false).setTrack(null).subscribe(null, LOG_LAV)); queue.clear();
manager.saveQueue(); stopPlayback();
current = null;
manager.saveQueue();
}
} }
public void shuffle() { public void shuffle() {
var list = new ArrayList<>(queue); synchronized (this) {
Collections.shuffle(list); var list = new ArrayList<>(queue);
queue.clear(); Collections.shuffle(list);
queue.addAll(list); queue.clear();
manager.saveQueueDebounced(); queue.addAll(list);
manager.saveQueueDebounced();
}
} }
public List<Track> getQueue() { public List<Track> getQueue() {
return List.copyOf(queue); synchronized (this) {
return List.copyOf(queue);
}
} }
public Track getCurrentTrack() { public Track getCurrentTrack() {
return manager.getPlayer().map(LavalinkPlayer::getTrack).orElse(null); return current;
} }
public boolean isRepeating() { public boolean isRepeating() {
@ -117,8 +146,23 @@ public class TrackScheduler {
void startTrack(final Track track) { void startTrack(final Track track) {
manager.cancelAutoLeaveTimer(); manager.cancelAutoLeaveTimer();
current = track;
var link = manager.getLavalinkManager().getOrCreateLink(manager.getGuildId()); var link = manager.getLavalinkManager().getOrCreateLink(manager.getGuildId());
trackStarter.startWithRetry(link, track); trackStarter.startWithRetry(
link,
track,
() -> {
synchronized (this) {
if (current == track) {
current = null;
}
}
manager.startAutoLeaveTimer();
});
}
public void syncCurrent(final Track track) {
current = track;
} }
void notifyLoadFailed() { void notifyLoadFailed() {
@ -127,7 +171,8 @@ public class TrackScheduler {
} }
} }
Track pollNext() { private void stopPlayback() {
return queue.poll(); manager.getPlayer().ifPresent(p -> p.setPaused(false).setTrack(null).subscribe(null, LOG_LAV));
current = null;
} }
} }

View file

@ -3,6 +3,7 @@ package creator.loki.feature.music.manage.player;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import creator.loki.feature.music.util.voice.VolumeControl;
import dev.arbjerg.lavalink.client.Link; import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.Track; import dev.arbjerg.lavalink.client.player.Track;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -11,8 +12,7 @@ import org.slf4j.LoggerFactory;
class TrackStarter { class TrackStarter {
private static final Logger log = LoggerFactory.getLogger(TrackStarter.class); private static final Logger log = LoggerFactory.getLogger(TrackStarter.class);
private static final int MAX_ATTEMPTS = 5; private static final int MAX_ATTEMPTS = 5;
private static final long RETRY_DELAY_PLAYER_NULL_MS = 300; private static final long RETRY_DELAY_MS = 500;
private static final long RETRY_DELAY_SUBSCRIBE_ERROR_MS = 500;
private final ScheduledExecutorService executor; private final ScheduledExecutorService executor;
@ -25,24 +25,29 @@ class TrackStarter {
* *
* @param link the Lavalink link to play the track on * @param link the Lavalink link to play the track on
* @param track the track to start * @param track the track to start
* @param onFinalFailure invoked when all attempts are exhausted
*/ */
void startWithRetry(final Link link, final Track track) { void startWithRetry(final Link link, final Track track, final Runnable onFinalFailure) {
startWithRetry(link, track, 0); startWithRetry(link, track, 0, onFinalFailure);
} }
private void startWithRetry(final Link link, final Track track, final int attempt) { private void startWithRetry(
final Link link, final Track track, final int attempt, final Runnable onFinalFailure) {
if (attempt > MAX_ATTEMPTS) { if (attempt > MAX_ATTEMPTS) {
if (onFinalFailure != null) {
onFinalFailure.run();
}
return; return;
} }
link.createOrUpdatePlayer() link.createOrUpdatePlayer()
.setTrack(track) .setTrack(track)
.setVolume(100) .setVolume(VolumeControl.current(link))
.subscribe( .subscribe(
null, null,
err -> err ->
executor.schedule( executor.schedule(
() -> startWithRetry(link, track, attempt + 1), () -> startWithRetry(link, track, attempt + 1, onFinalFailure),
RETRY_DELAY_SUBSCRIBE_ERROR_MS, RETRY_DELAY_MS,
TimeUnit.MILLISECONDS)); TimeUnit.MILLISECONDS));
} }
} }

View file

@ -40,39 +40,87 @@ class TrackSchedulerTest {
} }
@Test @Test
void enqueue_addsTrackToEmptyPlayer() { void enqueue_startsTrackWhenIdle() {
when(manager.getPlayer()).thenReturn(java.util.Optional.empty());
var track = mock(Track.class); var track = mock(Track.class);
scheduler.enqueue(track); scheduler.enqueue(track);
assertSame(track, scheduler.getCurrentTrack());
verify(manager, times(2)).cancelAutoLeaveTimer(); verify(manager, times(2)).cancelAutoLeaveTimer();
verify(lavalinkManager).getOrCreateLink(123L); verify(lavalinkManager).getOrCreateLink(123L);
} }
@Test @Test
void enqueue_addsTrackToQueueWhenPlayerBusy() { void enqueue_playlistOnlyStartsFirstTrack() {
when(player.getTrack()).thenReturn(mock(Track.class)); for (int i = 0; i < 5; i++) {
when(manager.getPlayer()).thenReturn(java.util.Optional.of(player)); scheduler.enqueue(mock(Track.class));
}
var track = mock(Track.class); assertNotNull(scheduler.getCurrentTrack());
scheduler.enqueue(track); assertEquals(4, scheduler.queue.size());
verify(lavalinkManager, times(1)).getOrCreateLink(123L);
assertEquals(1, scheduler.queue.size()); verify(manager, atLeastOnce()).saveQueueDebounced();
assertSame(track, scheduler.queue.peek());
verify(manager).cancelAutoLeaveTimer();
verify(manager).saveQueueDebounced();
} }
@Test @Test
void skip_clearsCurrentTrack() { void enqueue_addsTrackToQueueWhenAlreadyPlaying() {
when(manager.getPlayer()).thenReturn(java.util.Optional.of(player)); var playing = mock(Track.class);
when(player.setTrack(null)).thenReturn(builder); var queued = mock(Track.class);
scheduler.enqueue(playing);
scheduler.enqueue(queued);
assertEquals(1, scheduler.queue.size());
assertSame(queued, scheduler.queue.peek());
verify(manager, atLeastOnce()).saveQueueDebounced();
}
@Test
void skip_startsNextTrackFromQueue() {
var playing = mock(Track.class);
var next = mock(Track.class);
scheduler.enqueue(playing);
scheduler.enqueue(next);
scheduler.skip(); scheduler.skip();
verify(player).setTrack(null); assertSame(next, scheduler.getCurrentTrack());
verify(builder).subscribe(any(), any()); assertEquals(0, scheduler.queue.size());
verify(lavalinkManager, times(2)).getOrCreateLink(123L);
}
@Test
void skip_skipsSeveralTracks() {
scheduler.enqueue(mock(Track.class));
scheduler.enqueue(mock(Track.class));
var target = mock(Track.class);
scheduler.enqueue(target);
scheduler.skip(2);
assertSame(target, scheduler.getCurrentTrack());
}
@Test
void skip_clearsCurrentTrackWhenQueueEmpty() {
when(manager.getPlayer()).thenReturn(java.util.Optional.of(player));
when(player.setPaused(false)).thenReturn(builder);
when(player.setTrack(null)).thenReturn(builder);
scheduler.enqueue(mock(Track.class));
scheduler.skip();
assertNull(scheduler.getCurrentTrack());
verify(builder).setTrack(null);
verify(builder, times(2)).subscribe(any(), any());
verify(manager).startAutoLeaveTimer();
}
@Test
void nextTrack_emptyQueueStartsAutoLeave() {
assertFalse(scheduler.nextTrack());
assertNull(scheduler.getCurrentTrack());
verify(manager).startAutoLeaveTimer();
} }
@Test @Test
@ -86,6 +134,7 @@ class TrackSchedulerTest {
scheduler.stop(); scheduler.stop();
assertTrue(scheduler.queue.isEmpty()); assertTrue(scheduler.queue.isEmpty());
assertNull(scheduler.getCurrentTrack());
verify(manager).cancelAutoLeaveTimer(); verify(manager).cancelAutoLeaveTimer();
verify(manager).saveQueue(); verify(manager).saveQueue();
verify(builder).subscribe(any(), any()); verify(builder).subscribe(any(), any());