diff --git a/src/main/java/creator/loki/feature/music/commands/control/SkipCommand.java b/src/main/java/creator/loki/feature/music/commands/control/SkipCommand.java index 90f94d0..db7284c 100644 --- a/src/main/java/creator/loki/feature/music/commands/control/SkipCommand.java +++ b/src/main/java/creator/loki/feature/music/commands/control/SkipCommand.java @@ -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 + "**")) diff --git a/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java b/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java index f4da58a..dcfdffc 100644 --- a/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java +++ b/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java @@ -51,6 +51,7 @@ class LavalinkEventRouter { event -> { GuildMusicManager manager = musicManagers.get(event.getGuildId()); if (manager != null) { + manager.getScheduler().syncCurrent(event.getTrack()); manager.cancelAutoLeaveTimer(); } }); diff --git a/src/main/java/creator/loki/feature/music/manage/player/QueueStore.java b/src/main/java/creator/loki/feature/music/manage/player/QueueStore.java index f47cd3b..9932f14 100644 --- a/src/main/java/creator/loki/feature/music/manage/player/QueueStore.java +++ b/src/main/java/creator/loki/feature/music/manage/player/QueueStore.java @@ -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())); diff --git a/src/main/java/creator/loki/feature/music/manage/player/TrackEndHandler.java b/src/main/java/creator/loki/feature/music/manage/player/TrackEndHandler.java index 2b192e6..bd9a3f8 100644 --- a/src/main/java/creator/loki/feature/music/manage/player/TrackEndHandler.java +++ b/src/main/java/creator/loki/feature/music/manage/player/TrackEndHandler.java @@ -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(); + } } diff --git a/src/main/java/creator/loki/feature/music/manage/player/TrackScheduler.java b/src/main/java/creator/loki/feature/music/manage/player/TrackScheduler.java index c095d82..70024a7 100644 --- a/src/main/java/creator/loki/feature/music/manage/player/TrackScheduler.java +++ b/src/main/java/creator/loki/feature/music/manage/player/TrackScheduler.java @@ -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 queue = new LinkedList<>(); + private Track current; private boolean repeating; public final FilterState filterState = new FilterState(); private Consumer 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 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; } } diff --git a/src/main/java/creator/loki/feature/music/manage/player/TrackStarter.java b/src/main/java/creator/loki/feature/music/manage/player/TrackStarter.java index 38cb25e..933b4f5 100644 --- a/src/main/java/creator/loki/feature/music/manage/player/TrackStarter.java +++ b/src/main/java/creator/loki/feature/music/manage/player/TrackStarter.java @@ -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)); } } diff --git a/src/test/java/creator/loki/feature/music/manage/player/TrackSchedulerTest.java b/src/test/java/creator/loki/feature/music/manage/player/TrackSchedulerTest.java index cf1dc1d..0d70f99 100644 --- a/src/test/java/creator/loki/feature/music/manage/player/TrackSchedulerTest.java +++ b/src/test/java/creator/loki/feature/music/manage/player/TrackSchedulerTest.java @@ -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());