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();
for (int i = 0; i < amount - 1; i++) {
manager.getScheduler().nextTrack();
}
manager.getScheduler().skip();
manager.getScheduler().skip(amount);
context
.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Пропущено", "**" + skipped + "**"))

View file

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

View file

@ -61,14 +61,8 @@ class QueueStore {
if (decoded.isEmpty()) {
return;
}
var q = manager.getScheduler().queue;
q.addAll(decoded.subList(1, decoded.size()));
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);
for (var track : decoded) {
manager.getScheduler().enqueue(track);
}
LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков");
queueRepository.deleteQueue(String.valueOf(manager.getGuildId()));

View file

@ -18,38 +18,33 @@ class TrackEndHandler {
}
void onTrackEnd(final Track lastTrack, final AudioTrackEndReason endReason) {
if (endReason == AudioTrackEndReason.LOAD_FAILED) {
handleLoadFailed();
} else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) {
handleUnexpectedEnd(endReason);
} else if (scheduler.isRepeating() && endReason == AudioTrackEndReason.FINISHED) {
handleFinished(lastTrack);
} else {
nextOrAutoLeave();
switch (endReason) {
case LOAD_FAILED -> handleLoadFailed();
case FINISHED -> handleFinished(lastTrack);
case REPLACED, STOPPED -> {
// Track was intentionally replaced or stopped by skip()/stop(); the scheduler
// already advanced playback, so there is nothing to do here.
}
case CLEANUP -> handleCleanedUp();
}
}
private void handleLoadFailed() {
LoggerDecorator.warn(log, "Load failed, skipping to next");
scheduler.notifyLoadFailed();
nextOrAutoLeave();
}
private void handleUnexpectedEnd(final AudioTrackEndReason endReason) {
LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason);
manager.startAutoLeaveTimer();
scheduler.nextTrack();
}
private void handleFinished(final Track lastTrack) {
scheduler.startTrack(lastTrack.makeClone());
}
private void nextOrAutoLeave() {
var next = scheduler.pollNext();
if (next != null) {
scheduler.startTrack(next);
if (scheduler.isRepeating()) {
scheduler.startTrack(lastTrack.makeClone());
} 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 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.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason;
import org.slf4j.Logger;
@ -22,6 +21,7 @@ public class TrackScheduler {
private final TrackStarter trackStarter;
private final TrackEndHandler trackEndHandler;
final Queue<Track> queue = new LinkedList<>();
private Track current;
private boolean repeating;
public final FilterState filterState = new FilterState();
private Consumer<String> onLoadFailedNotify;
@ -38,49 +38,78 @@ public class TrackScheduler {
public void enqueue(final Track track) {
manager.cancelAutoLeaveTimer();
if (manager.getPlayer().filter(p -> p.getTrack() != null).isPresent()) {
synchronized (this) {
queue.offer(track);
manager.saveQueueDebounced();
} else {
startTrack(track);
if (current == null) {
nextTrack();
} else {
manager.saveQueueDebounced();
}
}
}
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() {
var next = queue.poll();
if (next == null) {
return false;
synchronized (this) {
var next = queue.poll();
if (next == null) {
current = null;
manager.startAutoLeaveTimer();
return false;
}
startTrack(next);
manager.saveQueueDebounced();
return true;
}
startTrack(next);
manager.saveQueueDebounced();
return true;
}
public void stop() {
manager.cancelAutoLeaveTimer();
queue.clear();
manager.getPlayer().ifPresent(p -> p.setPaused(false).setTrack(null).subscribe(null, LOG_LAV));
manager.saveQueue();
synchronized (this) {
manager.cancelAutoLeaveTimer();
queue.clear();
stopPlayback();
current = null;
manager.saveQueue();
}
}
public void shuffle() {
var list = new ArrayList<>(queue);
Collections.shuffle(list);
queue.clear();
queue.addAll(list);
manager.saveQueueDebounced();
synchronized (this) {
var list = new ArrayList<>(queue);
Collections.shuffle(list);
queue.clear();
queue.addAll(list);
manager.saveQueueDebounced();
}
}
public List<Track> getQueue() {
return List.copyOf(queue);
synchronized (this) {
return List.copyOf(queue);
}
}
public Track getCurrentTrack() {
return manager.getPlayer().map(LavalinkPlayer::getTrack).orElse(null);
return current;
}
public boolean isRepeating() {
@ -117,8 +146,23 @@ public class TrackScheduler {
void startTrack(final Track track) {
manager.cancelAutoLeaveTimer();
current = track;
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() {
@ -127,7 +171,8 @@ public class TrackScheduler {
}
}
Track pollNext() {
return queue.poll();
private void stopPlayback() {
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.TimeUnit;
import creator.loki.feature.music.util.voice.VolumeControl;
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.Track;
import org.slf4j.Logger;
@ -11,8 +12,7 @@ import org.slf4j.LoggerFactory;
class TrackStarter {
private static final Logger log = LoggerFactory.getLogger(TrackStarter.class);
private static final int MAX_ATTEMPTS = 5;
private static final long RETRY_DELAY_PLAYER_NULL_MS = 300;
private static final long RETRY_DELAY_SUBSCRIBE_ERROR_MS = 500;
private static final long RETRY_DELAY_MS = 500;
private final ScheduledExecutorService executor;
@ -25,24 +25,29 @@ class TrackStarter {
*
* @param link the Lavalink link to play the track on
* @param track the track to start
* @param onFinalFailure invoked when all attempts are exhausted
*/
void startWithRetry(final Link link, final Track track) {
startWithRetry(link, track, 0);
void startWithRetry(final Link link, final Track track, final Runnable onFinalFailure) {
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 (onFinalFailure != null) {
onFinalFailure.run();
}
return;
}
link.createOrUpdatePlayer()
.setTrack(track)
.setVolume(100)
.setVolume(VolumeControl.current(link))
.subscribe(
null,
err ->
executor.schedule(
() -> startWithRetry(link, track, attempt + 1),
RETRY_DELAY_SUBSCRIBE_ERROR_MS,
() -> startWithRetry(link, track, attempt + 1, onFinalFailure),
RETRY_DELAY_MS,
TimeUnit.MILLISECONDS));
}
}

View file

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