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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jest.mock('@lucky/shared/utils/monitoring', () => ({
import {
resolveQueryWithFallbacks,
emitPlayResolutionTelemetry,
preferExactMatch,
} from './resolveProvider'

describe('resolveQueryWithFallbacks', () => {
Expand Down Expand Up @@ -273,6 +274,120 @@ describe('resolveQueryWithFallbacks', () => {
expect(telemetry.latencyMs).toBeGreaterThanOrEqual(40)
})
})

describe('search relevance', () => {
it('passes an afterSearch reranker through on every arm', async () => {
const mockTrack = { title: 'Test Song' }
mockPlayer.play
.mockRejectedValueOnce(new Error('Primary failed'))
.mockRejectedValueOnce(new Error('YouTube failed'))
.mockResolvedValueOnce(mockTrack)

await resolveQueryWithFallbacks(
mockPlayer,
mockVoiceChannel,
'Prince',
'spotify',
QueryType.SPOTIFY_SEARCH,
mockPlayOptions,
)

for (const call of mockPlayer.play.mock.calls) {
expect(call[2].afterSearch).toEqual(expect.any(Function))
}
})
})
})

describe('preferExactMatch', () => {
it('promotes a track whose author exactly matches the query', async () => {
const tracks = [
{ title: 'Adicto', author: 'Prince Royce' },
{ title: 'Purple Rain', author: 'Prince' },
]
const result = {
hasPlaylist: () => false,
tracks,
setTracks: jest.fn(() => result),
}

await preferExactMatch('Prince')(result as any)

expect(result.setTracks).toHaveBeenCalledWith([
{ title: 'Purple Rain', author: 'Prince' },
{ title: 'Adicto', author: 'Prince Royce' },
])
})

it('promotes a track whose title exactly matches the query', async () => {
const tracks = [
{ title: 'Some Other Song', author: 'Someone Else' },
{ title: 'prince', author: 'A Tribute Band' },
]
const result = {
hasPlaylist: () => false,
tracks,
setTracks: jest.fn(() => result),
}

await preferExactMatch('Prince')(result as any)

expect(result.setTracks).toHaveBeenCalledWith([
{ title: 'prince', author: 'A Tribute Band' },
{ title: 'Some Other Song', author: 'Someone Else' },
])
})

it('leaves the result untouched when no exact match exists', async () => {
const tracks = [
{ title: 'Bohemian Rhapsody', author: 'Queen' },
{ title: 'Somebody to Love', author: 'Queen' },
]
const result = {
hasPlaylist: () => false,
tracks,
setTracks: jest.fn(() => result),
}

const returned = await preferExactMatch('bohemian rhapsody queen')(
result as any,
)

expect(result.setTracks).not.toHaveBeenCalled()
expect(returned).toBe(result)
})

it('leaves the result untouched when the exact match is already first', async () => {
const tracks = [
{ title: 'Purple Rain', author: 'Prince' },
{ title: 'Adicto', author: 'Prince Royce' },
]
const result = {
hasPlaylist: () => false,
tracks,
setTracks: jest.fn(() => result),
}

await preferExactMatch('Prince')(result as any)

expect(result.setTracks).not.toHaveBeenCalled()
})

it('skips reordering for playlist results', async () => {
const tracks = [
{ title: 'Adicto', author: 'Prince Royce' },
{ title: 'Purple Rain', author: 'Prince' },
]
const result = {
hasPlaylist: () => true,
tracks,
setTracks: jest.fn(() => result),
}

await preferExactMatch('Prince')(result as any)

expect(result.setTracks).not.toHaveBeenCalled()
})
})

describe('emitPlayResolutionTelemetry', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Player,
PlayerNodeInitializerOptions,
PlayerNodeInitializationResult,
SearchResult,
} from 'discord-player'
import type { VoiceBasedChannel } from 'discord.js'
import { warnLog } from '@lucky/shared/utils'
Expand All @@ -25,11 +26,38 @@ const TEXT_SEARCH_BLOCKED_EXTRACTORS = [
ATTACHMENT_EXTRACTOR_ID,
]

/**
* Some extractors (Spotify's raw search API in particular) return their own
* top hit as tracks[0] with no relevance re-ranking, so a short/ambiguous
* query like "Prince" can surface a same-genre act (e.g. Prince Royce)
* ahead of the artist the query actually names. This promotes a candidate
* whose title or author exactly matches the query, leaving descriptive
* queries (where no exact match exists) untouched.
*/
export function preferExactMatch(
query: string,
): (result: SearchResult) => Promise<SearchResult> {
const normalizedQuery = query.trim().toLowerCase()

return async (result) => {
if (result.hasPlaylist() || result.tracks.length <= 1) return result

const bestIndex = result.tracks.findIndex((track) => {
const author = track.author?.trim().toLowerCase()
const title = track.title?.trim().toLowerCase()
return author === normalizedQuery || title === normalizedQuery
})
if (bestIndex <= 0) return result

const reordered = [...result.tracks]
const [bestTrack] = reordered.splice(bestIndex, 1)
reordered.unshift(bestTrack)
return result.setTracks(reordered)
}
}

export type PlayResolutionArm =
| 'primary'
| 'youtube-fallback'
| 'soundcloud-fallback'
| 'failed'
'primary' | 'youtube-fallback' | 'soundcloud-fallback' | 'failed'

interface ResolutionTelemetry {
resolvedVia: PlayResolutionArm
Expand Down Expand Up @@ -59,10 +87,18 @@ export async function resolveQueryWithFallbacks(
latencyMs: 0,
requestedProvider,
}
const resolvedPlayOptions = {
...playOptions,
afterSearch: preferExactMatch(query),
}

try {
// Attempt primary resolution
const result = await player.play(voiceChannel, query, playOptions)
const result = await player.play(
voiceChannel,
query,
resolvedPlayOptions,
)
telemetry.latencyMs = Date.now() - startTime
telemetry.resolvedVia = 'primary'
return { result, telemetry }
Expand All @@ -82,7 +118,7 @@ export async function resolveQueryWithFallbacks(
// Attempt YouTube fallback. See TEXT_SEARCH_BLOCKED_EXTRACTORS
// for why these are excluded.
const result = await player.play(voiceChannel, query, {
...playOptions,
...resolvedPlayOptions,
searchEngine: QueryType.YOUTUBE_SEARCH,
blockExtractors: TEXT_SEARCH_BLOCKED_EXTRACTORS,
})
Expand All @@ -99,7 +135,7 @@ export async function resolveQueryWithFallbacks(
try {
// Attempt SoundCloud fallback — same block reason as above
const result = await player.play(voiceChannel, query, {
...playOptions,
...resolvedPlayOptions,
searchEngine: QueryType.SOUNDCLOUD_SEARCH,
blockExtractors: TEXT_SEARCH_BLOCKED_EXTRACTORS,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'

const SPOTIFY_EXTRACTOR_ID = 'com.discord-player.itsmaat.spotifyextractor'
import type { CustomClient } from '../../../../types'
import { preferExactMatch } from './handlers/resolveProvider'
import {
requireVoiceChannel,
requireDJRole,
Expand Down Expand Up @@ -195,10 +196,12 @@ export async function executePlayAtTop({

try {
const searchEngine = resolveSearchEngine(query)
const afterSearch = preferExactMatch(query)
let result
try {
result = await client.player.play(voiceChannel, query, {
searchEngine,
afterSearch,
})
} catch (primaryError) {
if (searchEngine !== QueryType.AUTO) {
Expand All @@ -214,6 +217,7 @@ export async function executePlayAtTop({
result = await client.player.play(voiceChannel, query, {
searchEngine: QueryType.YOUTUBE_SEARCH,
blockExtractors: [SPOTIFY_EXTRACTOR_ID],
afterSearch,
})
} catch (youtubeError) {
warnLog({
Expand All @@ -224,6 +228,7 @@ export async function executePlayAtTop({
result = await client.player.play(voiceChannel, query, {
searchEngine: QueryType.SOUNDCLOUD_SEARCH,
blockExtractors: [SPOTIFY_EXTRACTOR_ID],
afterSearch,
})
}
} else {
Expand Down
Loading