Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/bot/src/handlers/player/lifecycleHandlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -34,6 +35,10 @@ jest.mock('../../utils/music/watchdog', () => ({
},
}))

jest.mock('../../utils/music/queueOperations', () => ({
replenishQueue: (...args: unknown[]) => replenishQueueMock(...args),
}))

import {
setupLifecycleHandlers,
setupVoiceKickDetection,
Expand Down Expand Up @@ -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<string, PlayerEventHandler> = {}
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<string, PlayerEventHandler> = {}
const player = {
events: {
Expand All @@ -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()
})
})

Expand Down
8 changes: 7 additions & 1 deletion packages/bot/src/handlers/player/lifecycleHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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) => {
Expand Down
103 changes: 103 additions & 0 deletions packages/bot/src/handlers/player/queueExhaustion.spec.ts
Original file line number Diff line number Diff line change
@@ -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<GuildQueue>
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)
})
})
35 changes: 35 additions & 0 deletions packages/bot/src/handlers/player/queueExhaustion.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> {
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 })
}
}
6 changes: 6 additions & 0 deletions packages/bot/src/handlers/player/trackHandlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
23 changes: 3 additions & 20 deletions packages/bot/src/handlers/player/trackHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
isReplenishSuppressed,
setReplenishSuppressed,
} from '../../utils/music/replenishSuppressionStore'
import { handleQueueExhaustion } from './queueExhaustion'

const MAX_GUILD_ENTRIES = 500

Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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 })
}
Expand Down
8 changes: 8 additions & 0 deletions packages/bot/src/utils/music/autoplay/replenisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading