diff --git a/packages/bot/src/handlers/player/lifecycleHandlers.spec.ts b/packages/bot/src/handlers/player/lifecycleHandlers.spec.ts index 8b47b7f52..a6e19f2e0 100644 --- a/packages/bot/src/handlers/player/lifecycleHandlers.spec.ts +++ b/packages/bot/src/handlers/player/lifecycleHandlers.spec.ts @@ -10,6 +10,7 @@ const watchdogCheckRecoverMock = jest.fn() const watchdogClearMock = jest.fn() const watchdogMarkIntentionalStopMock = jest.fn() const watchdogIsIntentionalStopMock = jest.fn(() => false) +const replenishQueueMock = jest.fn() jest.mock('@lucky/shared/utils', () => ({ debugLog: (...args: unknown[]) => debugLogMock(...args), @@ -34,6 +35,10 @@ jest.mock('../../utils/music/watchdog', () => ({ }, })) +jest.mock('../../utils/music/queueOperations', () => ({ + replenishQueue: (...args: unknown[]) => replenishQueueMock(...args), +})) + import { setupLifecycleHandlers, setupVoiceKickDetection, @@ -148,7 +153,32 @@ describe('setupLifecycleHandlers', () => { expect(watchdogCheckRecoverMock).not.toHaveBeenCalled() }) - it('marks intentional stop on emptyQueue', async () => { + it('replenishes queue on emptyQueue when autoplay is enabled', async () => { + const handlers: Record = {} + const player = { + events: { + on: jest.fn((event: string, handler: PlayerEventHandler) => { + handlers[event] = handler + }), + }, + } + + setupLifecycleHandlers(player) + + const track = { id: 'track-1', title: 'Test' } + const queue = { + guild: { id: 'guild-5', name: 'Guild 5' }, + repeatMode: 3, + currentTrack: track, + } as unknown as GuildQueue + + await handlers.emptyQueue(queue) + + expect(replenishQueueMock).toHaveBeenCalledWith(queue) + expect(watchdogMarkIntentionalStopMock).not.toHaveBeenCalled() + }) + + it('marks intentional stop on emptyQueue when autoplay is disabled', async () => { const handlers: Record = {} const player = { events: { @@ -162,11 +192,14 @@ describe('setupLifecycleHandlers', () => { const queue = { guild: { id: 'guild-5', name: 'Guild 5' }, + repeatMode: 2, + currentTrack: null, } as unknown as GuildQueue await handlers.emptyQueue(queue) expect(watchdogMarkIntentionalStopMock).toHaveBeenCalledWith('guild-5') + expect(replenishQueueMock).not.toHaveBeenCalled() }) }) diff --git a/packages/bot/src/handlers/player/lifecycleHandlers.ts b/packages/bot/src/handlers/player/lifecycleHandlers.ts index 780a754aa..e03d1a056 100644 --- a/packages/bot/src/handlers/player/lifecycleHandlers.ts +++ b/packages/bot/src/handlers/player/lifecycleHandlers.ts @@ -5,6 +5,7 @@ import * as voiceStatus from '../../services/VoiceChannelStatusService' import { ENVIRONMENT_CONFIG } from '@lucky/shared/config' import { musicWatchdogService } from '../../utils/music/watchdog' import { musicSessionSnapshotService } from '../../utils/music/sessionSnapshots' +import { replenishQueue } from '../../utils/music/queueOperations' import type { QueueMetadata } from '../../types/QueueMetadata' export const setupVoiceKickDetection = (client: Client): void => { @@ -76,7 +77,12 @@ export const setupLifecycleHandlers = (player: { }) player.events.on('emptyQueue', async (queue: GuildQueue) => { - musicWatchdogService.markIntentionalStop(queue.guild.id) + const isAutoplayEnabled = queue.repeatMode === 3 + if (isAutoplayEnabled && queue.currentTrack) { + await replenishQueue(queue) + } else { + musicWatchdogService.markIntentionalStop(queue.guild.id) + } }) player.events.on('disconnect', async (queue: GuildQueue) => { diff --git a/packages/bot/src/handlers/player/queueExhaustion.spec.ts b/packages/bot/src/handlers/player/queueExhaustion.spec.ts new file mode 100644 index 000000000..860a6626f --- /dev/null +++ b/packages/bot/src/handlers/player/queueExhaustion.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals' +import type { GuildQueue, Track } from 'discord-player' + +const watchdogIsIntentionalStopMock = jest.fn() +const watchdogArmMock = jest.fn() +const watchdogClearMock = jest.fn() +const saveSnapshotMock = jest.fn() +const clearSnapshotIfStaleMock = jest.fn() +const clearStatusMock = jest.fn() +const setReplenishSuppressedMock = jest.fn() +const errorLogMock = jest.fn() + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: errorLogMock, +})) + +jest.mock('../../utils/music/watchdog', () => ({ + musicWatchdogService: { + isIntentionalStop: watchdogIsIntentionalStopMock, + arm: watchdogArmMock, + clear: watchdogClearMock, + }, +})) + +jest.mock('../../utils/music/sessionSnapshots', () => ({ + musicSessionSnapshotService: { + saveSnapshot: saveSnapshotMock, + clearSnapshotIfStale: clearSnapshotIfStaleMock, + }, +})) + +jest.mock('../../services/VoiceChannelStatusService', () => ({ + clearStatus: clearStatusMock, +})) + +jest.mock('../../utils/music/replenishSuppressionStore', () => ({ + setReplenishSuppressed: setReplenishSuppressedMock, +})) + +const { handleQueueExhaustion } = require('./queueExhaustion') + +describe('handleQueueExhaustion', () => { + let mockQueue: Partial + let mockReplenishIfAutoplay: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + mockQueue = { + guild: { id: 'guild123' }, + currentTrack: null, + tracks: { size: 0 }, + } + mockReplenishIfAutoplay = jest.fn() + watchdogIsIntentionalStopMock.mockReturnValue(false) + }) + + it('should return early if intentional stop', async () => { + watchdogIsIntentionalStopMock.mockReturnValue(true) + + await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay) + + expect(mockReplenishIfAutoplay).not.toHaveBeenCalled() + }) + + it('should call replenishIfAutoplay and save snapshot when queue has tracks', async () => { + mockQueue.tracks = { size: 3 } as any + saveSnapshotMock.mockResolvedValue(undefined) + watchdogArmMock.mockReturnValue(undefined) + + await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay) + + expect(mockReplenishIfAutoplay).toHaveBeenCalledWith(mockQueue) + expect(saveSnapshotMock).toHaveBeenCalledWith(mockQueue) + expect(watchdogArmMock).toHaveBeenCalledWith(mockQueue) + }) + + it('should call replenishIfAutoplay and save snapshot when currentTrack exists', async () => { + mockQueue.currentTrack = { id: 'track123' } as Track + saveSnapshotMock.mockResolvedValue(undefined) + watchdogArmMock.mockReturnValue(undefined) + + await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay) + + expect(mockReplenishIfAutoplay).toHaveBeenCalledWith(mockQueue) + expect(saveSnapshotMock).toHaveBeenCalledWith(mockQueue) + expect(watchdogArmMock).toHaveBeenCalledWith(mockQueue) + }) + + it('should handle queue exhaustion when both currentTrack and tracks are empty', async () => { + clearStatusMock.mockResolvedValue(undefined) + saveSnapshotMock.mockResolvedValue(undefined) + clearSnapshotIfStaleMock.mockResolvedValue(undefined) + + await handleQueueExhaustion(mockQueue as GuildQueue, mockReplenishIfAutoplay) + + expect(mockReplenishIfAutoplay).toHaveBeenCalledWith(mockQueue) + expect(saveSnapshotMock).toHaveBeenCalledWith(mockQueue) + expect(clearStatusMock).toHaveBeenCalledWith(mockQueue) + expect(watchdogClearMock).toHaveBeenCalledWith('guild123') + expect(setReplenishSuppressedMock).toHaveBeenCalledWith('guild123', 35_000) + expect(clearSnapshotIfStaleMock).toHaveBeenCalledWith(mockQueue) + }) +}) diff --git a/packages/bot/src/handlers/player/queueExhaustion.ts b/packages/bot/src/handlers/player/queueExhaustion.ts new file mode 100644 index 000000000..eac188f51 --- /dev/null +++ b/packages/bot/src/handlers/player/queueExhaustion.ts @@ -0,0 +1,35 @@ +import type { GuildQueue, Track } from 'discord-player' +import { errorLog } from '@lucky/shared/utils' +import { musicWatchdogService } from '../../utils/music/watchdog' +import { musicSessionSnapshotService } from '../../utils/music/sessionSnapshots' +import * as voiceStatus from '../../services/VoiceChannelStatusService' +import { + setReplenishSuppressed, +} from '../../utils/music/replenishSuppressionStore' + +/** + * Handles queue exhaustion logic shared between finish and skip events. + * Manages replenishment suppression, snapshot clearing, watchdog arming, + * and voice status updates when queue becomes empty. + */ +export async function handleQueueExhaustion( + queue: GuildQueue, + replenishIfAutoplay: (queue: GuildQueue, track?: Track) => Promise, +): Promise { + try { + if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return + await replenishIfAutoplay(queue) + await musicSessionSnapshotService.saveSnapshot(queue) + + if (queue.currentTrack || queue.tracks.size > 0) { + musicWatchdogService.arm(queue) + } else { + await voiceStatus.clearStatus(queue) + musicWatchdogService.clear(queue.guild.id) + setReplenishSuppressed(queue.guild.id, 35_000) + await musicSessionSnapshotService.clearSnapshotIfStale(queue) + } + } catch (error) { + errorLog({ message: 'Error handling queue exhaustion:', error }) + } +} diff --git a/packages/bot/src/handlers/player/trackHandlers.spec.ts b/packages/bot/src/handlers/player/trackHandlers.spec.ts index 0b5573285..6a7c29384 100644 --- a/packages/bot/src/handlers/player/trackHandlers.spec.ts +++ b/packages/bot/src/handlers/player/trackHandlers.spec.ts @@ -75,9 +75,15 @@ jest.mock('../../utils/music/watchdog', () => ({ jest.mock('../../utils/music/sessionSnapshots', () => ({ musicSessionSnapshotService: { saveSnapshot: (...args: unknown[]) => saveSnapshotMock(...args), + clearSnapshotIfStale: jest.fn().mockResolvedValue(undefined), }, })) +jest.mock('../../utils/music/replenishSuppressionStore', () => ({ + isReplenishSuppressed: jest.fn(() => false), + setReplenishSuppressed: jest.fn(), +})) + jest.mock('../../services/VoiceChannelStatusService', () => ({ clearStatus: (...args: unknown[]) => clearStatusMock(...args), setTrackStatus: jest.fn(), diff --git a/packages/bot/src/handlers/player/trackHandlers.ts b/packages/bot/src/handlers/player/trackHandlers.ts index 71abc8613..277972508 100644 --- a/packages/bot/src/handlers/player/trackHandlers.ts +++ b/packages/bot/src/handlers/player/trackHandlers.ts @@ -25,6 +25,7 @@ import { isReplenishSuppressed, setReplenishSuppressed, } from '../../utils/music/replenishSuppressionStore' +import { handleQueueExhaustion } from './queueExhaustion' const MAX_GUILD_ENTRIES = 500 @@ -266,16 +267,7 @@ const handlePlayerFinish = async ( guildTrackStartTimes.delete(queue.guild.id) } - if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return - await replenishIfAutoplay(queue, track) - await musicSessionSnapshotService.saveSnapshot(queue) - - if (queue.currentTrack || queue.tracks.size > 0) { - musicWatchdogService.arm(queue) - } else { - await voiceStatus.clearStatus(queue) - musicWatchdogService.clear(queue.guild.id) - } + await handleQueueExhaustion(queue, (q, t) => replenishIfAutoplay(q, t ?? track)) } catch (error) { errorLog({ message: 'Error in playerFinish event:', error }) } @@ -312,16 +304,7 @@ const handlePlayerSkip = async ( guildTrackStartTimes.delete(queue.guild.id) } - if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return - await replenishIfAutoplay(queue, track) - await musicSessionSnapshotService.saveSnapshot(queue) - - if (queue.currentTrack || queue.tracks.size > 0) { - musicWatchdogService.arm(queue) - } else { - await voiceStatus.clearStatus(queue) - musicWatchdogService.clear(queue.guild.id) - } + await handleQueueExhaustion(queue, (q, t) => replenishIfAutoplay(q, t ?? track)) } catch (error) { errorLog({ message: 'Error in playerSkip event:', error }) } diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index ed6cc1777..88a8c2405 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -371,6 +371,14 @@ async function _replenishQueue( }, }) replenishCounters.set(guildId, replenishCount + 1) + debugLog({ + message: 'Autoplay replenish exhausted: all candidate sources returned empty', + data: { + guildId, + currentTrack: currentTrack?.title, + autoplayMode, + }, + }) return } diff --git a/packages/bot/src/utils/music/sessionSnapshots.spec.ts b/packages/bot/src/utils/music/sessionSnapshots.spec.ts index 52e75edb8..b4b30fba6 100644 --- a/packages/bot/src/utils/music/sessionSnapshots.spec.ts +++ b/packages/bot/src/utils/music/sessionSnapshots.spec.ts @@ -480,3 +480,97 @@ describe('MusicSessionSnapshotService', () => { title: 'Next Song', }) }) + + it('returns null on save error and logs', async () => { + setexMock.mockRejectedValue(new Error('redis down')) + const service = new MusicSessionSnapshotService(300) + const queue = { + guild: { id: 'guild-err' }, + currentTrack: { + title: 't', + author: 'a', + url: 'u', + duration: '1', + source: 'youtube', + }, + tracks: { toArray: () => [] }, + } as unknown as GuildQueue + + const result = await service.saveSnapshot(queue) + expect(result).toBeNull() + }) + + it('returns null on getSnapshot redis read error', async () => { + getMock.mockRejectedValue(new Error('redis down')) + const service = new MusicSessionSnapshotService(300) + const result = await service.getSnapshot('g') + expect(result).toBeNull() + }) + + it('returns null on getSnapshot when key missing', async () => { + getMock.mockResolvedValue(null) + const service = new MusicSessionSnapshotService(300) + const result = await service.getSnapshot('g') + expect(result).toBeNull() + }) + + it('swallows deleteSnapshot redis errors', async () => { + delMock.mockRejectedValue(new Error('redis down')) + const service = new MusicSessionSnapshotService(300) + await expect(service.deleteSnapshot('g')).resolves.toBeUndefined() + }) + + it('clearSnapshotIfStale is no-op when queue has upcoming tracks', async () => { + const service = new MusicSessionSnapshotService(300) + const queue = { + guild: { id: 'g' }, + currentTrack: { title: 't' }, + tracks: { size: 3 }, + } as unknown as GuildQueue + await service.clearSnapshotIfStale(queue) + expect(getMock).not.toHaveBeenCalled() + }) + + it('clearSnapshotIfStale is no-op when no current track', async () => { + const service = new MusicSessionSnapshotService(300) + const queue = { + guild: { id: 'g' }, + currentTrack: null, + tracks: { size: 0 }, + } as unknown as GuildQueue + await service.clearSnapshotIfStale(queue) + expect(getMock).not.toHaveBeenCalled() + }) + + it('clearSnapshotIfStale skips when snapshot has upcoming tracks', async () => { + getMock.mockResolvedValue( + JSON.stringify({ + sessionSnapshotId: 's', + guildId: 'g', + savedAt: Date.now(), + currentTrack: null, + upcomingTracks: [{ title: 'x', author: 'y', url: 'z', duration: '1', source: 's' }], + }), + ) + const service = new MusicSessionSnapshotService(300) + const queue = { + guild: { id: 'g' }, + currentTrack: { title: 't' }, + tracks: { size: 0 }, + } as unknown as GuildQueue + await service.clearSnapshotIfStale(queue) + expect(delMock).not.toHaveBeenCalled() + }) + + it('restoreSnapshot returns 0 when queue already has tracks', async () => { + const service = new MusicSessionSnapshotService(300) + const queue = { + guild: { id: 'g' }, + currentTrack: { title: 't' }, + tracks: { size: 5 }, + } as unknown as GuildQueue + const result = await service.restoreSnapshot(queue) + expect(result.restoredCount).toBe(0) + expect(result.sessionSnapshotId).toBeNull() + }) +}) diff --git a/packages/bot/src/utils/music/sessionSnapshots.ts b/packages/bot/src/utils/music/sessionSnapshots.ts index 844e47e59..2049cd097 100644 --- a/packages/bot/src/utils/music/sessionSnapshots.ts +++ b/packages/bot/src/utils/music/sessionSnapshots.ts @@ -137,6 +137,27 @@ export class MusicSessionSnapshotService { } } + /** + * Clears the snapshot for a guild when only the current track exists. + * Used to prevent watchdog from re-enqueuing the same song on orphan recovery. + */ + async clearSnapshotIfStale(queue: GuildQueue): Promise { + if (!queue.currentTrack || queue.tracks.size > 0) { + return + } + + const snapshot = await this.getSnapshot(queue.guild.id) + if (!snapshot || snapshot.upcomingTracks.length > 0) { + return + } + + await this.deleteSnapshot(queue.guild.id) + debugLog({ + message: 'Cleared stale snapshot (only current track)', + data: { guildId: queue.guild.id }, + }) + } + async getSnapshot(guildId: string): Promise { try { const raw = await redisClient.get(this.getKey(guildId))