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:
parent
a7f7c1019e
commit
5e83356016
7 changed files with 172 additions and 86 deletions
|
|
@ -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 + "**"))
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class LavalinkEventRouter {
|
|||
event -> {
|
||||
GuildMusicManager manager = musicManagers.get(event.getGuildId());
|
||||
if (manager != null) {
|
||||
manager.getScheduler().syncCurrent(event.getTrack());
|
||||
manager.cancelAutoLeaveTimer();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue