From 3d69ca997f49c35684c2694bb96a078dfd421dd4 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 15 Mar 2026 13:15:52 -0300 Subject: [PATCH] feat(bot): add probe-based URL validation to /queue rescue (#267) - Add probeTrackResolvable() helper using player.search + timeout race - Enhance rescueQueue() with RescueQueueOptions (probeResolvable, probeTimeoutMs, refillThreshold) - Wire probe=true in /queue rescue action handler - Add QUEUE_RESCUE_PROBE_TIMEOUT_MS and QUEUE_RESCUE_REFILL_THRESHOLD env constants - Add 2 new tests: probe removes dead tracks, probe timeout treats as unresolvable - Update CHANGELOG.md [Unreleased] section Closes #267 --- CHANGELOG.md | 5 ++ .../music/commands/queue/index.spec.ts | 2 +- .../functions/music/commands/queue/index.ts | 2 +- .../src/utils/music/queueManipulation.spec.ts | 71 ++++++++++++++++++- .../bot/src/utils/music/queueManipulation.ts | 69 ++++++++++++++++-- 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22710d86e..c7529e6cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/music health` now shows a **Recommendation feedback** field with the count of tracks the user has disliked, giving visibility into autoplay filtering state without leaving the health embed (#271). +- `/queue rescue` now uses **probe-based URL validation** (`player.search` with + configurable timeout, default 5 s) to detect actually unresolvable tracks + (removed videos, geo-blocked content, dead links) in addition to the existing + structural check. Configurable via `QUEUE_RESCUE_PROBE_TIMEOUT_MS` and + `QUEUE_RESCUE_REFILL_THRESHOLD` env vars. ### Changed diff --git a/packages/bot/src/functions/music/commands/queue/index.spec.ts b/packages/bot/src/functions/music/commands/queue/index.spec.ts index 3f80322be..1a7841b4e 100644 --- a/packages/bot/src/functions/music/commands/queue/index.spec.ts +++ b/packages/bot/src/functions/music/commands/queue/index.spec.ts @@ -140,7 +140,7 @@ describe('queue command', () => { interaction: createInteraction('rescue'), } as any) - expect(rescueQueueMock).toHaveBeenCalledWith(queue) + expect(rescueQueueMock).toHaveBeenCalledWith(queue, { probeResolvable: true }) expect(successEmbedMock).toHaveBeenCalledWith( 'Queue rescue complete', expect.stringContaining('Removed 1 broken track(s)'), diff --git a/packages/bot/src/functions/music/commands/queue/index.ts b/packages/bot/src/functions/music/commands/queue/index.ts index 48bedaee8..40e0caa7c 100644 --- a/packages/bot/src/functions/music/commands/queue/index.ts +++ b/packages/bot/src/functions/music/commands/queue/index.ts @@ -105,7 +105,7 @@ export default new Command({ } if (action === 'rescue') { - const result = await rescueQueue(queue) + const result = await rescueQueue(queue, { probeResolvable: true }) await interactionReply({ interaction, content: { diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index d8e9b88bd..33003af55 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -492,7 +492,7 @@ describe('queueManipulation.queueOperations', () => { currentTrack: playableTrack, } as unknown as GuildQueue - const result = await rescueQueue(queue) + const result = await rescueQueue(queue, { refillThreshold: 0 }) expect(result).toEqual({ removedTracks: 1, @@ -502,4 +502,73 @@ describe('queueManipulation.queueOperations', () => { expect((queue as any).clear).toHaveBeenCalled() expect((queue as any).addTrack).toHaveBeenCalledWith(playableTrack) }) + + it('probe-based rescue removes tracks that fail player.search', async () => { + const resolvableTrack = { + title: 'Good Track', + author: 'Artist A', + url: 'https://youtube.com/good', + } as Track + const deadTrack = { + title: 'Dead Track', + author: 'Artist B', + url: 'https://youtube.com/removed', + } as Track + const searchMock = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ tracks: [resolvableTrack] }), + ) + .mockImplementationOnce(() => Promise.resolve({ tracks: [] })) + const queue = { + player: { search: searchMock }, + tracks: { + toArray: jest + .fn() + .mockReturnValue([resolvableTrack, deadTrack]), + size: 2, + }, + clear: jest.fn(), + addTrack: jest.fn(), + currentTrack: null, + } as unknown as GuildQueue + + const result = await rescueQueue(queue, { + probeResolvable: true, + refillThreshold: 0, + }) + + expect(result.removedTracks).toBe(1) + expect(result.keptTracks).toBe(1) + expect((queue as any).addTrack).toHaveBeenCalledWith(resolvableTrack) + expect((queue as any).addTrack).not.toHaveBeenCalledWith(deadTrack) + }) + + it('probe-based rescue treats timed-out probe as unresolvable', async () => { + const track = { + title: 'Stalled Track', + author: 'Artist', + url: 'https://youtube.com/stalled', + } as Track + const searchMock = jest.fn().mockImplementation( + () => new Promise(() => { /* never resolves */ }), + ) + const queue = { + player: { search: searchMock }, + tracks: { toArray: jest.fn().mockReturnValue([track]), size: 1 }, + clear: jest.fn(), + addTrack: jest.fn(), + currentTrack: null, + } as unknown as GuildQueue + + const result = await rescueQueue(queue, { + probeResolvable: true, + probeTimeoutMs: 50, + refillThreshold: 0, + }) + + expect(result.removedTracks).toBe(1) + expect(result.keptTracks).toBe(0) + expect((queue as any).addTrack).not.toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index 11a406fc2..73161de48 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -14,6 +14,14 @@ const HISTORY_SEED_LIMIT = 3 const SEARCH_RESULTS_LIMIT = 8 const MAX_TRACKS_PER_ARTIST = 2 const MAX_TRACKS_PER_SOURCE = 3 +const QUEUE_RESCUE_PROBE_TIMEOUT_MS = Number.parseInt( + process.env.QUEUE_RESCUE_PROBE_TIMEOUT_MS ?? '5000', + 10, +) +const QUEUE_RESCUE_REFILL_THRESHOLD = Number.parseInt( + process.env.QUEUE_RESCUE_REFILL_THRESHOLD ?? '3', + 10, +) type ScoredTrack = { track: Track @@ -406,13 +414,64 @@ function isPlayableTrack(track: Track): boolean { return Boolean(track.url) && Boolean(track.title) && Boolean(track.author) } +async function probeTrackResolvable( + queue: GuildQueue, + track: Track, + timeoutMs: number, +): Promise { + const query = track.url || `${track.title} ${track.author}`.trim() + try { + const result = await Promise.race([ + queue.player.search(query, { searchEngine: QueryType.AUTO }), + new Promise((resolve) => + setTimeout(() => resolve(null), timeoutMs), + ), + ]) + return result !== null && result.tracks.length > 0 + } catch { + return false + } +} + +export type RescueQueueOptions = { + probeResolvable?: boolean + probeTimeoutMs?: number + refillThreshold?: number +} + export async function rescueQueue( queue: GuildQueue, + opts: RescueQueueOptions = {}, ): Promise { + const { + probeResolvable = false, + probeTimeoutMs = QUEUE_RESCUE_PROBE_TIMEOUT_MS, + refillThreshold = QUEUE_RESCUE_REFILL_THRESHOLD, + } = opts + try { const tracks = queue.tracks.toArray() - const keptTracks = tracks.filter((track) => isPlayableTrack(track)) - const removedTracks = tracks.length - keptTracks.length + const keptTracks: Track[] = [] + let removedTracks = 0 + + for (const track of tracks) { + if (!isPlayableTrack(track)) { + removedTracks++ + continue + } + if (probeResolvable) { + const resolvable = await probeTrackResolvable( + queue, + track, + probeTimeoutMs, + ) + if (!resolvable) { + removedTracks++ + continue + } + } + keptTracks.push(track) + } queue.clear() for (const track of keptTracks) { @@ -420,11 +479,7 @@ export async function rescueQueue( } const beforeReplenish = queue.tracks.size - if ( - queue.repeatMode === QueueRepeatMode.AUTOPLAY && - queue.currentTrack && - queue.tracks.size < 4 - ) { + if (queue.currentTrack && queue.tracks.size < refillThreshold) { await replenishQueue(queue) } const addedTracks = Math.max(0, queue.tracks.size - beforeReplenish)