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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down
2 changes: 1 addition & 1 deletion packages/bot/src/functions/music/commands/queue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
71 changes: 70 additions & 1 deletion packages/bot/src/utils/music/queueManipulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
})
})
69 changes: 62 additions & 7 deletions packages/bot/src/utils/music/queueManipulation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
QueryType,
QueueRepeatMode,

Check warning on line 3 in packages/bot/src/utils/music/queueManipulation.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'QueueRepeatMode'.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZzzJxvwtHWeJ9ZZZ12V&open=AZzzJxvwtHWeJ9ZZZ12V&pullRequest=278
type Track,
type GuildQueue,
} from 'discord-player'
Expand All @@ -14,6 +14,14 @@
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
Expand Down Expand Up @@ -406,25 +414,72 @@
return Boolean(track.url) && Boolean(track.title) && Boolean(track.author)
}

async function probeTrackResolvable(
queue: GuildQueue,
track: Track,
timeoutMs: number,
): Promise<boolean> {
const query = track.url || `${track.title} ${track.author}`.trim()
try {
const result = await Promise.race([
queue.player.search(query, { searchEngine: QueryType.AUTO }),
new Promise<null>((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<QueueRescueResult> {
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) {
queue.addTrack(track)
}

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)
Expand Down
Loading