import { VideoInfo } from '../../types/index.js'; interface SearchResult { url: string; info: VideoInfo; diff: number; } interface MatchResult extends SearchResult { type: string; priority: number; } type ProgressFn = ( stage: string, progress: number, message?: string, details?: string ) => void; interface ResolveMetadata { title: string; artist: string; duration: number; isrc?: string; album?: string; year?: string | number; imageUrl?: string; } interface RaceCandidate { type: string; priority: number; promise: Promise; isFinished?: boolean; } // common context for search candidates interface RaceContext { videoURL: string; metadata: ResolveMetadata; cookieArgs: string[]; signal: AbortSignal; onProgress: ProgressFn; soundchartsPromise: Promise<{ isrc?: string } | null> | null; isStopped: () => boolean; } const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); function getYtdlpService(): Promise { return import('../ytdlp.service.js'); } import { refineSearchWithAI } from './ai.js'; import { fetchFromOdesli, fetchIsrcFromDeezer, fetchIsrcFromItunes, } from './external.js'; import { resolveSideTasks } from './metadata.js'; interface YtSearchVideo { id: string; title?: { toString: () => string }; author?: { name?: string }; thumbnails?: Array<{ url: string }>; duration?: { seconds?: number }; } interface SoundCloudTrack { permalink_url: string; } const _cleanYoutubeQuery = (query: string) => { return query .replace(/on Spotify/gu, '') .replace(/-/gu, ' ') .trim(); }; const _buildYoutubeVideoInfo = (video: YtSearchVideo): VideoInfo => { const durationSeconds = video.duration?.seconds || 0; const webpageUrl = `https://www.youtube.com/watch?v=${video.id}`; const authorName = video.author?.name || ''; return { type: 'video', id: video.id, title: video.title?.toString() || '', uploader: authorName, author: authorName, thumbnail: video.thumbnails?.[0]?.url || '', webpageUrl, duration: durationSeconds, formats: [], extractorKey: 'youtube', isJsInfo: true, fromBrain: false, isPartial: false, isIsrcMatch: false, isFullData: false, }; }; async function searchOnYoutube( query: string, cookieArgs: string[], targetMetadata: { duration?: number }, _onEarlyDispatch: (() => void) | null = null, _skipPlayerOptimization = false, signal: AbortSignal | null = null, retryCount = 0 ): Promise { const cleanQuery = _cleanYoutubeQuery(query); const targetDurationMs = targetMetadata?.duration || 0; try { const { getYoutubeClient } = await import('../extractors/youtube/client.js'); const youtubeClient = await getYoutubeClient(); const searchResults = await youtubeClient.search(cleanQuery, { type: 'video', }); if (!searchResults?.videos?.length) { throw new Error('No videos found'); } const firstVideo = searchResults.videos[0] as unknown as YtSearchVideo; const info = _buildYoutubeVideoInfo(firstVideo); const durationMs = (info.duration || 0) * 1000; const drift = targetDurationMs > 0 && durationMs > 0 ? Math.abs(durationMs - targetDurationMs) : 0; const ytdlp = await getYtdlpService(); ytdlp.cacheVideoInfo(info.webpageUrl as string, info, cookieArgs); return { url: info.webpageUrl as string, info, diff: drift }; } catch (error: unknown) { if (retryCount < 1 && !signal?.aborted) { await new Promise((resolve) => setTimeout(resolve, 1000)); return searchOnYoutube( query, cookieArgs, targetMetadata, _onEarlyDispatch, _skipPlayerOptimization, signal, retryCount + 1 ); } console.debug(`[YouTubeSearch] Error: ${(error as Error).message}`); return null; } } function _evaluateCandidate( result: SearchResult, candidate: { type: string; priority: number }, targetDuration: number, currentBest: MatchResult | null ): MatchResult | null { const driftLimit = candidate.priority <= 1 ? 120000 : 25000; if (targetDuration > 0 && result.diff > driftLimit) return currentBest; if (candidate.priority === 0) return { ...result, type: candidate.type, priority: candidate.priority }; if (result.diff < 2000) { if (!currentBest || candidate.priority < currentBest.priority) { return { ...result, type: candidate.type, priority: candidate.priority }; } } if ( !currentBest || candidate.priority < currentBest.priority || (candidate.priority === currentBest.priority && result.diff < currentBest.diff) ) { return { ...result, type: candidate.type, priority: candidate.priority }; } return currentBest; } function priorityRace( candidates: RaceCandidate[], metadata: { duration: number }, settleCallback?: (reason: string) => void ): Promise { return new Promise((resolve) => { let bestMatch: MatchResult | null = null; let finishedCount = 0; let isSettled = false; const settle = (match: MatchResult | null, reason = '') => { if (!isSettled) { isSettled = true; if (settleCallback) settleCallback(reason); resolve(match); } }; for (const candidate of candidates) { candidate.isFinished = false; candidate.promise .then((result: SearchResult | null) => { candidate.isFinished = true; if (isSettled) return; finishedCount++; if (result) { bestMatch = _evaluateCandidate( result, candidate, metadata.duration, bestMatch ); if ( candidate.priority === 0 && bestMatch?.type === candidate.type ) { settle(bestMatch, `${candidate.type} verified`); return; } } if (finishedCount >= candidates.length) { setTimeout(() => settle(bestMatch, 'all candidates finished'), 500); } }) .catch(() => { candidate.isFinished = true; if (isSettled) return; finishedCount++; if (finishedCount >= candidates.length) settle(bestMatch, 'all candidates finished'); }); } }); } async function searchOnSoundCloud( query: string, targetMetadata?: { duration: number } ): Promise { try { const soundCloudModule = await import('../extractors/soundcloud.js'); const searchResults = (await soundCloudModule.search( query )) as SoundCloudTrack[]; if (!searchResults?.length) return null; const info = await soundCloudModule.getInfo(searchResults[0].permalink_url); const targetDurationMs = targetMetadata?.duration ?? 0; const drift = targetDurationMs > 0 ? Math.abs((info.duration || 0) * 1000 - targetDurationMs) : 0; return { url: searchResults[0].permalink_url, info, diff: drift }; } catch (error: unknown) { console.error( `[Race] SoundCloud search failed: ${(error as Error).message}` ); return null; } } // resolve authoritative ISRC for exact match async function buildIsrcCandidate( ctx: RaceContext ): Promise { const { metadata, cookieArgs, signal, onProgress, soundchartsPromise } = ctx; if (ctx.isStopped()) return null; let isrc: string | null | undefined = metadata.isrc ?? (soundchartsPromise ? (await soundchartsPromise)?.isrc : null); if (!isrc) { const results = await Promise.race([ Promise.all([ fetchIsrcFromDeezer( metadata.title, metadata.artist, null, metadata.duration ).catch(() => null), fetchIsrcFromItunes( metadata.title, metadata.artist, null, metadata.duration ).catch(() => null), ]), new Promise((resolve) => setTimeout(() => resolve([null, null]), 3000) ), ]); isrc = results?.[0]?.isrc ?? results?.[1]?.isrc; } if (!isrc || ctx.isStopped()) return null; onProgress('initializing', 35, 'Matching by ISRC...'); return searchOnYoutube(`"${isrc}"`, cookieArgs, metadata, null, true, signal); } async function buildSoundCloudCandidate( ctx: RaceContext ): Promise { const { metadata, onProgress } = ctx; await delay(200); if (ctx.isStopped()) return null; onProgress('initializing', 40, 'Searching SoundCloud...'); return searchOnSoundCloud(`${metadata.title} ${metadata.artist}`, metadata); } async function buildOdesliCandidate( ctx: RaceContext ): Promise { const { videoURL, metadata, cookieArgs, signal, onProgress } = ctx; if (!videoURL || ctx.isStopped()) return null; onProgress('initializing', 45, 'Querying Odesli...'); const response = await fetchFromOdesli(videoURL).catch(() => null); if (!response?.targetUrl || ctx.isStopped()) return null; const ytdlp = await getYtdlpService(); const info = await ytdlp .getVideoInfo(response.targetUrl, cookieArgs, false, signal) .catch(() => null); if (!info) return null; return { url: response.targetUrl, info, diff: Math.abs((info.duration || 0) * 1000 - metadata.duration), }; } async function buildAiCandidate( ctx: RaceContext ): Promise { const { metadata, cookieArgs, signal, onProgress } = ctx; await delay(1000); if (ctx.isStopped()) return null; onProgress('initializing', 55, 'Refining search with AI...'); const aiResult = await refineSearchWithAI({ ...metadata, album: metadata.album || 'Unknown', year: metadata.year as string | number, }).catch(() => null); if (!aiResult?.query || ctx.isStopped()) return null; return searchOnYoutube( aiResult.query, cookieArgs, metadata, null, false, signal ); } async function buildCleanArtistCandidate( ctx: RaceContext, cleanArtist: string ): Promise { const { metadata, cookieArgs, signal, onProgress } = ctx; await delay(500); if (ctx.isStopped()) return null; onProgress('initializing', 65, 'Searching by cleaned artist name...'); return searchOnYoutube( `${metadata.title} ${cleanArtist}`, cookieArgs, metadata, null, false, signal ); } // clean names to improve search hits const ARTIST_SUFFIX_REGEX = /\s+(?:Music|Band|Official|Topic|TV)\s*$/iu; const RACE_TIMEOUT_MS = 45000; export async function runPriorityRace( videoURL: string, metadata: ResolveMetadata, cookieArgs: string[], onProgress: ProgressFn, soundchartsPromise: Promise<{ isrc?: string } | null> | null = null ): Promise { const raceController = new AbortController(); const { signal } = raceController; let raceSettled = false; const ctx: RaceContext = { videoURL, metadata, cookieArgs, signal, onProgress, soundchartsPromise, isStopped: () => raceSettled, }; onProgress('initializing', 25, 'Searching multiple sources...'); // ensure ISRC match persists after race const isrcPromise = buildIsrcCandidate(ctx); const candidates: RaceCandidate[] = [ { type: 'ISRC', priority: 0, promise: isrcPromise }, { type: 'SoundCloud', priority: 1, promise: buildSoundCloudCandidate(ctx) }, { type: 'Odesli', priority: 1, promise: buildOdesliCandidate(ctx) }, { type: 'AI', priority: 2, promise: buildAiCandidate(ctx) }, ]; const cleanArtist = metadata.artist.replace(ARTIST_SUFFIX_REGEX, '').trim(); if (cleanArtist) { candidates.push({ type: 'Clean', priority: 2, promise: buildCleanArtistCandidate(ctx, cleanArtist), }); } const raceTimeoutId = setTimeout(() => { if (!raceSettled) raceController.abort(); }, RACE_TIMEOUT_MS); try { const bestMatch = await priorityRace(candidates, metadata, () => { raceSettled = true; raceController.abort(); clearTimeout(raceTimeoutId); onProgress('initializing', 80, 'Match resolved.'); }); const [isrcResult] = await Promise.all([ isrcPromise.catch(() => null), resolveSideTasks(videoURL, metadata).catch(() => null), ]); if ( isrcResult && (!bestMatch || (bestMatch.type !== 'ISRC' && isrcResult.diff <= 2000)) ) { return { ...isrcResult, type: 'ISRC', priority: 0 }; } if (!bestMatch) throw new Error('No high-quality YouTube matches found.'); return bestMatch; } catch (error) { raceSettled = true; raceController.abort(); clearTimeout(raceTimeoutId); throw error; } }