diff --git a/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts b/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts new file mode 100644 index 000000000..99692b3b7 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/spotifyRecommender.spec.ts @@ -0,0 +1,468 @@ +import { jest } from '@jest/globals' +import type { Track, GuildQueue } from 'discord-player' +import { QueryType } from 'discord-player' +import type { SpotifyAudioFeatures } from '../../../spotify/spotifyApi' +import { + collectSpotifyRecommendationCandidates, + searchSeedCandidates, +} from './spotifyRecommender' +import type { SessionMood } from './sessionMood' + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + warnLog: jest.fn(), +})) + +const spotifyLinkServiceMock = jest.fn() +const searchSpotifyTrackMock = jest.fn() +const getSpotifyRecommendationsMock = jest.fn() + +jest.mock('@lucky/shared/services', () => ({ + spotifyLinkService: { + getValidAccessToken: (...args: unknown[]) => + spotifyLinkServiceMock(...args), + }, +})) + +jest.mock('../../../spotify/spotifyApi', () => ({ + searchSpotifyTrack: (...args: unknown[]) => + searchSpotifyTrackMock(...args), + getSpotifyRecommendations: (...args: unknown[]) => + getSpotifyRecommendationsMock(...args), +})) + +jest.mock('../searchQueryCleaner', () => ({ + cleanTitle: (s: string) => s, + cleanAuthor: (s: string) => s, + extractSongCore: jest.fn(() => null), + cleanSearchQuery: (title: string) => title, +})) + +function createTrack(overrides: Partial = {}): Track { + return { + title: 'Test Song', + author: 'Test Artist', + durationMS: 3 * 60 * 1000, + url: 'https://open.spotify.com/track/testid', + source: 'spotify', + ...overrides, + } as Track +} + +function createMockQueue(overrides: Partial = {}): GuildQueue { + return { + player: { + search: jest.fn(), + }, + ...overrides, + } as unknown as GuildQueue +} + +describe('spotifyRecommender', () => { + beforeEach(() => { + jest.clearAllMocks() + spotifyLinkServiceMock.mockResolvedValue('test-token') + searchSpotifyTrackMock.mockResolvedValue('spotify-id') + getSpotifyRecommendationsMock.mockResolvedValue([]) + }) + + describe('collectSpotifyRecommendationCandidates', () => { + it('returns early if requestedBy is null', async () => { + const queue = createMockQueue() + const candidates = new Map() + + await collectSpotifyRecommendationCandidates( + queue, + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + ) + + expect(candidates.size).toBe(0) + expect(spotifyLinkServiceMock).not.toHaveBeenCalled() + }) + + it('returns early if no valid token', async () => { + spotifyLinkServiceMock.mockRejectedValue(new Error('No token')) + const queue = createMockQueue() + const candidates = new Map() + const user = { id: 'user123' } as any + + await collectSpotifyRecommendationCandidates( + queue, + [createTrack()], + user, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + ) + + expect(candidates.size).toBe(0) + }) + + it('returns early if no seed IDs found', async () => { + const queue = createMockQueue() + const candidates = new Map() + const user = { id: 'user123' } as any + const track = createTrack({ url: 'https://youtube.com/watch?v=test' }) + + await collectSpotifyRecommendationCandidates( + queue, + [track], + user, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + ) + + expect(candidates.size).toBe(0) + }) + + it('extracts Spotify track IDs from URLs', async () => { + getSpotifyRecommendationsMock.mockResolvedValue([ + { id: 'rec-id-1' }, + ] as any) + + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: [createTrack({ durationMS: 3 * 60 * 1000 })], + }) + + const candidates = new Map() + const user = { id: 'user123' } as any + const track = createTrack({ + url: 'https://open.spotify.com/track/abc123def456', + }) + + await collectSpotifyRecommendationCandidates( + queueMock, + [track], + user, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + ) + + expect(getSpotifyRecommendationsMock).toHaveBeenCalledWith( + 'test-token', + ['abc123def456'], + 15, + undefined, + ) + }) + + it('filters out tracks exceeding max duration', async () => { + getSpotifyRecommendationsMock.mockResolvedValue([ + { id: 'rec-id-1' }, + ] as any) + + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: [ + createTrack({ durationMS: 8 * 60 * 1000 }), + createTrack({ durationMS: 3 * 60 * 1000 }), + ], + }) + + const candidates = new Map() + const user = { id: 'user123' } as any + + await collectSpotifyRecommendationCandidates( + queueMock, + [createTrack({ url: 'spotify:track:abc123' })], + user, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + ) + + expect(candidates.size).toBeGreaterThan(0) + }) + + it('respects audio feature constraints', async () => { + const audioConstraints: SpotifyAudioFeatures = { + energy: 0.5, + valence: 0.6, + danceability: 0.7, + acousticness: 0.2, + instrumentalness: 0.1, + liveness: 0.3, + loudness: -5, + speechiness: 0.04, + tempo: 120, + key: 0, + mode: 1, + time_signature: 4, + } + + getSpotifyRecommendationsMock.mockResolvedValue([ + { id: 'rec-id-1' }, + ] as any) + + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: [createTrack()], + }) + + const candidates = new Map() + const user = { id: 'user123' } as any + + await collectSpotifyRecommendationCandidates( + queueMock, + [createTrack({ url: 'spotify:track:abc123' })], + user, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + audioConstraints, + ) + + expect(getSpotifyRecommendationsMock).toHaveBeenCalledWith( + 'test-token', + ['abc123'], + 15, + { + energy: 0.5, + valence: 0.6, + danceability: 0.7, + }, + ) + }) + }) + + describe('searchSeedCandidates', () => { + it('returns empty array on failure', async () => { + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock).mockRejectedValue( + new Error('Search failed'), + ) + + const user = { id: 'user123' } as any + + const result = await searchSeedCandidates( + queueMock, + createTrack(), + user, + 0, + ) + + expect(result).toEqual([]) + }) + + it('returns tracks from first successful engine', async () => { + const queueMock = createMockQueue() + const tracks = [createTrack({ title: 'Result 1' })] + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks, + }) + + const user = { id: 'user123' } as any + + const result = await searchSeedCandidates( + queueMock, + createTrack(), + user, + 0, + ) + + expect(result).toEqual(tracks) + expect(queueMock.player.search).toHaveBeenCalledWith( + expect.any(String), + { + requestedBy: user, + searchEngine: QueryType.SPOTIFY_SEARCH, + }, + ) + }) + + it('tries fallback engines on failure', async () => { + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock) + .mockResolvedValueOnce({ tracks: [] }) + .mockResolvedValueOnce({ + tracks: [createTrack({ title: 'YouTube result' })], + }) + + const user = { id: 'user123' } as any + + const result = await searchSeedCandidates( + queueMock, + createTrack(), + user, + 0, + ) + + expect(result.length).toBeGreaterThan(0) + expect(queueMock.player.search).toHaveBeenCalledTimes(2) + }) + + it('applies query modifiers based on replenishCount for non-Spotify', async () => { + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock) + .mockResolvedValueOnce({ tracks: [] }) + .mockResolvedValueOnce({ + tracks: [createTrack()], + }) + + const user = { id: 'user123' } as any + + await searchSeedCandidates( + queueMock, + createTrack({ title: 'Test', author: 'Artist' }), + user, + 1, + ) + + const youtubeCall = (queueMock.player.search as jest.Mock).mock + .calls[1][0] + expect(youtubeCall).toContain('similar') + }) + + it('filters tracks exceeding max duration', async () => { + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: [ + createTrack({ durationMS: 8 * 60 * 1000 }), + createTrack({ durationMS: 3 * 60 * 1000 }), + createTrack({ durationMS: 2 * 60 * 1000 }), + ], + }) + + const user = { id: 'user123' } as any + + const result = await searchSeedCandidates( + queueMock, + createTrack(), + user, + 0, + ) + + expect(result.length).toBeLessThanOrEqual(2) + result.forEach((track) => { + if (track.durationMS) { + expect(track.durationMS).toBeLessThanOrEqual(7 * 60 * 1000) + } + }) + }) + + it('limits results to SEARCH_RESULTS_LIMIT', async () => { + const queueMock = createMockQueue() + const manyTracks = Array.from({ length: 20 }, (_, i) => + createTrack({ title: `Song ${i}` }), + ) + ;(queueMock.player.search as jest.Mock).mockResolvedValue({ + tracks: manyTracks, + }) + + const user = { id: 'user123' } as any + + const result = await searchSeedCandidates( + queueMock, + createTrack(), + user, + 0, + ) + + expect(result.length).toBeLessThanOrEqual(8) + }) + + it('uses different search query for non-Spotify engines', async () => { + const queueMock = createMockQueue() + ;(queueMock.player.search as jest.Mock) + .mockResolvedValueOnce({ tracks: [] }) + .mockResolvedValueOnce({ + tracks: [createTrack({ source: 'youtube' })], + }) + + const user = { id: 'user123' } as any + + await searchSeedCandidates( + queueMock, + createTrack(), + user, + 0, + ) + + const calls = (queueMock.player.search as jest.Mock).mock.calls + const spotifyCall = calls[0][0] + const youtubeCall = calls[1][0] + + expect(typeof spotifyCall).toBe('string') + expect(typeof youtubeCall).toBe('string') + }) + }) +}) diff --git a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts new file mode 100644 index 000000000..91b058af2 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts @@ -0,0 +1,261 @@ +import { + QueryType, + type Track, + type GuildQueue, +} from 'discord-player' +import type { User } from 'discord.js' +import { debugLog, warnLog } from '@lucky/shared/utils' +import { spotifyLinkService } from '@lucky/shared/services' +import { + searchSpotifyTrack, + getSpotifyRecommendations, + type SpotifyAudioFeatures, +} from '../../../spotify/spotifyApi' +import { + cleanTitle, + cleanAuthor, + extractSongCore, + cleanSearchQuery, +} from '../searchQueryCleaner' +import type { SessionMood } from './sessionMood' +import { + shouldIncludeCandidate, + calculateRecommendationScore, + upsertScoredCandidate, + normalizeTrackKey, + normalizeText, + extractSpotifyTrackId, +} from '../queueManipulation' + +const MAX_AUTOPLAY_DURATION_MS = 7 * 60 * 1000 +const SEARCH_RESULTS_LIMIT = 8 +const QUERY_MODIFIERS = ['', 'similar', 'like', 'playlist', 'mix'] + +type ScoredTrack = { + track: Track + score: number + reason: string +} + +export async function collectSpotifyRecommendationCandidates( + queue: GuildQueue, + seedTracks: Track[], + requestedBy: User | null, + excludedUrls: Set, + excludedKeys: Set, + dislikedWeights: Map, + likedWeights: Map, + preferredArtistKeys: Set, + blockedArtistKeys: Set, + currentTrack: Track, + recentArtists: Set, + candidates: Map, + autoplayMode: 'similar' | 'discover' | 'popular', + artistFrequency: Map, + implicitDislikeKeys: Set, + implicitLikeKeys: Set, + sessionMood: SessionMood | null, + currentFeatures: SpotifyAudioFeatures | null, +): Promise { + if (!requestedBy) return + const token = await Promise.resolve( + spotifyLinkService.getValidAccessToken(requestedBy.id), + ).catch(() => null) + if (!token) return + + const seedIds = seedTracks + .map(extractSpotifyTrackId) + .filter((id): id is string => id !== null) + .slice(0, 5) + + if (seedIds.length === 0) { + const resolved = await Promise.allSettled( + seedTracks.slice(0, 3).map((s) => { + const core = extractSongCore(s.title ?? '', s.author) + return searchSpotifyTrack( + token, + core ?? cleanTitle(s.title ?? ''), + cleanAuthor(s.author), + ) + }), + ) + resolved.forEach((r) => { + if (r.status === 'fulfilled' && r.value) seedIds.push(r.value) + }) + } + + if (seedIds.length === 0) return + const audioConstraints = currentFeatures + ? { + energy: currentFeatures.energy, + valence: currentFeatures.valence, + danceability: currentFeatures.danceability, + } + : undefined + const recs = await getSpotifyRecommendations( + token, + seedIds, + 15, + audioConstraints, + ) + if (recs.length === 0) return + + debugLog({ + message: 'Autoplay: Spotify recommendations fetched', + data: { count: recs.length, seedCount: seedIds.length }, + }) + + const searchResults = await Promise.allSettled( + recs.map((rec) => { + const spotifyUrl = `https://open.spotify.com/track/${rec.id}` + return queue.player.search(spotifyUrl, { + requestedBy: requestedBy ?? undefined, + searchEngine: QueryType.SPOTIFY_SEARCH, + }) + }), + ) + + for (const result of searchResults) { + if (result.status !== 'fulfilled') continue + const track = result.value.tracks.find( + (t) => !t.durationMS || t.durationMS <= MAX_AUTOPLAY_DURATION_MS, + ) + if (!track) continue + if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys)) continue + const normalizedKey = normalizeTrackKey(track.title, track.author) + const dislikedWeight = dislikedWeights.get(normalizedKey) + if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue + const rec = calculateRecommendationScore( + track, + currentTrack, + recentArtists, + likedWeights, + preferredArtistKeys, + blockedArtistKeys, + autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, + dislikedWeights, + sessionMood, + ) + if (rec.score === -Infinity) continue + upsertScoredCandidate(candidates, track, { + score: rec.score + 0.3, + reason: rec.reason ? `${rec.reason} • spotify rec` : 'spotify rec', + }) + } +} + +export async function searchSeedCandidates( + queue: GuildQueue, + seed: Track, + requestedBy: User | null, + replenishCount = 0, +): Promise { + const baseQuery = cleanSearchQuery(seed.title, seed.author) + const modifier = QUERY_MODIFIERS[replenishCount % QUERY_MODIFIERS.length] + const query = modifier ? `${baseQuery} ${modifier}` : baseQuery + + const cleanedTitle = cleanTitle(seed.title) + const cleanedAuthor = cleanAuthor(seed.author) + const authorNorm = normalizeText(cleanedAuthor) + const authorInTitle = + authorNorm.length >= 3 && + normalizeText(cleanedTitle).includes( + authorNorm.slice(0, Math.min(5, authorNorm.length)), + ) + + let spotifyBase: string + if (authorInTitle) { + spotifyBase = cleanedTitle + } else { + const songCore = extractSongCore(seed.title, seed.author) + if (songCore) { + const titleArtist = extractTitleArtistFromSong( + cleanedTitle, + songCore, + ) + spotifyBase = `${songCore} ${titleArtist ?? cleanedAuthor}`.trim() + } else { + spotifyBase = baseQuery + } + } + const spotifyQuery = spotifyBase + + const engines: QueryType[] = [ + QueryType.SPOTIFY_SEARCH, + QueryType.YOUTUBE_SEARCH, + QueryType.AUTO, + ] + + for (const [idx, engine] of engines.entries()) { + const engineQuery = + engine === QueryType.SPOTIFY_SEARCH ? spotifyQuery : query + try { + const searchResult = await queue.player.search(engineQuery, { + requestedBy: requestedBy ?? undefined, + searchEngine: engine, + }) + + const tracks = searchResult.tracks + .filter( + (t) => + !t.durationMS || + t.durationMS <= MAX_AUTOPLAY_DURATION_MS, + ) + .slice(0, SEARCH_RESULTS_LIMIT) + + if (tracks.length > 0) { + if (idx > 0) { + warnLog({ + message: + 'Autoplay: Spotify returned 0 results, using fallback', + data: { + fallbackEngine: engine, + spotifyQuery, + fallbackQuery: engineQuery, + }, + }) + } + return tracks + } + if (engine === QueryType.SPOTIFY_SEARCH) { + debugLog({ + message: 'Autoplay: Spotify search returned 0 results', + data: { spotifyQuery }, + }) + } + } catch (error) { + debugLog({ + message: 'Search failed for seed, trying next engine', + data: { query: engineQuery, engine, error: String(error) }, + }) + } + } + + return [] +} + +function extractTitleArtistFromSong( + cleanedTitle: string, + songCore: string, +): string | null { + const normCore = normalizeText(songCore) + const corePrefix = normCore.slice(0, Math.min(6, normCore.length)) + for (const sep of [' - ', ' – ', ' — ']) { + const idx = cleanedTitle.indexOf(sep) + if (idx < 2 || idx > 60) continue + const left = cleanedTitle.slice(0, idx).trim() + if (/[()[\]]/.test(left) || left.length < 2) continue + const right = cleanedTitle.slice(idx + sep.length).trim() + if ( + corePrefix.length >= 3 && + normalizeText(left).startsWith(corePrefix) + ) { + return right + } + return left + } + return null +} diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index d6fc4ab45..6d3864a85 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -29,6 +29,10 @@ import { consumeBlendedSeedSlice, } from './autoplay/lastFmSeeds' import { detectSessionMood, type SessionMood } from './autoplay/sessionMood' +import { + collectSpotifyRecommendationCandidates, + searchSeedCandidates, +} from './autoplay/spotifyRecommender' import { buildExcludedUrls, buildExcludedKeys, @@ -692,122 +696,13 @@ function buildArtistFrequency( return freq } -function extractSpotifyTrackId(track: Track): string | null { +export function extractSpotifyTrackId(track: Track): string | null { const match = track.url?.match(/open\.spotify\.com\/track\/([A-Za-z0-9]+)/) ?? track.url?.match(/^spotify:track:([A-Za-z0-9]+)/) return match?.[1] ?? null } -async function collectSpotifyRecommendationCandidates( - queue: GuildQueue, - seedTracks: Track[], - requestedBy: User | null, - excludedUrls: Set, - excludedKeys: Set, - dislikedWeights: Map, - likedWeights: Map, - preferredArtistKeys: Set, - blockedArtistKeys: Set, - currentTrack: Track, - recentArtists: Set, - candidates: Map, - autoplayMode: 'similar' | 'discover' | 'popular', - artistFrequency: Map, - implicitDislikeKeys: Set, - implicitLikeKeys: Set, - sessionMood: SessionMood | null, - currentFeatures: SpotifyAudioFeatures | null, -): Promise { - if (!requestedBy) return - const token = await Promise.resolve( - spotifyLinkService.getValidAccessToken(requestedBy.id), - ).catch(() => null) - if (!token) return - - const seedIds = seedTracks - .map(extractSpotifyTrackId) - .filter((id): id is string => id !== null) - .slice(0, 5) - - if (seedIds.length === 0) { - const resolved = await Promise.allSettled( - seedTracks.slice(0, 3).map((s) => { - const core = extractSongCore(s.title ?? '', s.author) - return searchSpotifyTrack( - token, - core ?? cleanTitle(s.title ?? ''), - cleanAuthor(s.author), - ) - }), - ) - resolved.forEach((r) => { - if (r.status === 'fulfilled' && r.value) seedIds.push(r.value) - }) - } - - if (seedIds.length === 0) return - const audioConstraints = currentFeatures - ? { - energy: currentFeatures.energy, - valence: currentFeatures.valence, - danceability: currentFeatures.danceability, - } - : undefined - const recs = await getSpotifyRecommendations( - token, - seedIds, - 15, - audioConstraints, - ) - if (recs.length === 0) return - - debugLog({ - message: 'Autoplay: Spotify recommendations fetched', - data: { count: recs.length, seedCount: seedIds.length }, - }) - - const searchResults = await Promise.allSettled( - recs.map((rec) => { - const spotifyUrl = `https://open.spotify.com/track/${rec.id}` - return queue.player.search(spotifyUrl, { - requestedBy: requestedBy ?? undefined, - searchEngine: QueryType.SPOTIFY_SEARCH, - }) - }), - ) - - for (const result of searchResults) { - if (result.status !== 'fulfilled') continue - const track = result.value.tracks.find( - (t) => !t.durationMS || t.durationMS <= MAX_AUTOPLAY_DURATION_MS, - ) - if (!track) continue - if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys)) continue - const normalizedKey = normalizeTrackKey(track.title, track.author) - const dislikedWeight = dislikedWeights.get(normalizedKey) - if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue - const rec = calculateRecommendationScore( - track, - currentTrack, - recentArtists, - likedWeights, - preferredArtistKeys, - blockedArtistKeys, - autoplayMode, - artistFrequency, - implicitDislikeKeys, - implicitLikeKeys, - dislikedWeights, - sessionMood, - ) - if (rec.score === -Infinity) continue - upsertScoredCandidate(candidates, track, { - score: rec.score + 0.3, - reason: rec.reason ? `${rec.reason} • spotify rec` : 'spotify rec', - }) - } -} async function collectRecommendationCandidates( queue: GuildQueue, @@ -897,120 +792,7 @@ async function collectRecommendationCandidates( } const MAX_AUTOPLAY_DURATION_MS = 10 * 60 * 1000 -const QUERY_MODIFIERS = ['', 'similar', 'like', 'playlist', 'mix'] - -function extractTitleArtistFromSong( - cleanedTitle: string, - songCore: string, -): string | null { - const normCore = normalizeText(songCore) - const corePrefix = normCore.slice(0, Math.min(6, normCore.length)) - for (const sep of [' - ', ' – ', ' — ']) { - const idx = cleanedTitle.indexOf(sep) - if (idx < 2 || idx > 60) continue - const left = cleanedTitle.slice(0, idx).trim() - if (/[()[\]]/.test(left) || left.length < 2) continue - const right = cleanedTitle.slice(idx + sep.length).trim() - if ( - corePrefix.length >= 3 && - normalizeText(left).startsWith(corePrefix) - ) { - return right - } - return left - } - return null -} - -async function searchSeedCandidates( - queue: GuildQueue, - seed: Track, - requestedBy: User | null, - replenishCount = 0, -): Promise { - const baseQuery = cleanSearchQuery(seed.title, seed.author) - const modifier = QUERY_MODIFIERS[replenishCount % QUERY_MODIFIERS.length] - const query = modifier ? `${baseQuery} ${modifier}` : baseQuery - - const cleanedTitle = cleanTitle(seed.title) - const cleanedAuthor = cleanAuthor(seed.author) - const authorNorm = normalizeText(cleanedAuthor) - const authorInTitle = - authorNorm.length >= 3 && - normalizeText(cleanedTitle).includes( - authorNorm.slice(0, Math.min(5, authorNorm.length)), - ) - - let spotifyBase: string - if (authorInTitle) { - spotifyBase = cleanedTitle - } else { - const songCore = extractSongCore(seed.title, seed.author) - if (songCore) { - const titleArtist = extractTitleArtistFromSong( - cleanedTitle, - songCore, - ) - spotifyBase = `${songCore} ${titleArtist ?? cleanedAuthor}`.trim() - } else { - spotifyBase = baseQuery - } - } - const spotifyQuery = spotifyBase - - const engines: QueryType[] = [ - QueryType.SPOTIFY_SEARCH, - QueryType.YOUTUBE_SEARCH, - QueryType.AUTO, - ] - - for (const [idx, engine] of engines.entries()) { - const engineQuery = - engine === QueryType.SPOTIFY_SEARCH ? spotifyQuery : query - try { - const searchResult = await queue.player.search(engineQuery, { - requestedBy: requestedBy ?? undefined, - searchEngine: engine, - }) - - const tracks = searchResult.tracks - .filter( - (t) => - !t.durationMS || - t.durationMS <= MAX_AUTOPLAY_DURATION_MS, - ) - .slice(0, SEARCH_RESULTS_LIMIT) - - if (tracks.length > 0) { - if (idx > 0) { - warnLog({ - message: - 'Autoplay: Spotify returned 0 results, using fallback', - data: { - fallbackEngine: engine, - spotifyQuery, - fallbackQuery: engineQuery, - }, - }) - } - return tracks - } - if (engine === QueryType.SPOTIFY_SEARCH) { - debugLog({ - message: 'Autoplay: Spotify search returned 0 results', - data: { spotifyQuery }, - }) - } - } catch (error) { - debugLog({ - message: 'Search failed for seed, trying next engine', - data: { query: engineQuery, engine, error: String(error) }, - }) - } - } - return [] -} async function collectBroadFallbackCandidates( queue: GuildQueue, @@ -1087,7 +869,7 @@ async function collectBroadFallbackCandidates( } } -function shouldIncludeCandidate( +export function shouldIncludeCandidate( track: Track, excludedUrls: Set, excludedKeys: Set, @@ -1095,7 +877,7 @@ function shouldIncludeCandidate( return !isDuplicateCandidate(track, excludedUrls, excludedKeys) } -function upsertScoredCandidate( +export function upsertScoredCandidate( candidates: Map, candidate: Track, recommendation: { score: number; reason: string }, @@ -1597,7 +1379,7 @@ function stripFeaturing(author: string): string { return author } -function normalizeTrackKey(title?: string, author?: string): string { +export function normalizeTrackKey(title?: string, author?: string): string { const cleanedTitle = title ? cleanTitle(title) : '' const primaryAuthor = author ? stripFeaturing(cleanAuthor(author).split(',')[0] ?? '').trim() @@ -1609,7 +1391,7 @@ function normalizeTitleOnly(title?: string): string { return normalizeText(title ? cleanTitle(title) : '') } -function normalizeText(value?: string): string { +export function normalizeText(value?: string): string { return (value ?? '') .toLowerCase() .replaceAll(/[^a-z0-9]+/g, '') @@ -1701,7 +1483,7 @@ export function calculateGenreFamilyPenalty( return isStrongGenre ? -0.6 : -0.3 } -function calculateRecommendationScore( +export function calculateRecommendationScore( candidate: Track, currentTrack: Track, recentArtists: Set,