/** * Parallel local vs network search — first successful backend wins. */ import type { SearchResults } from '../../api/subsonicTypes'; export type SearchRaceSource = 'local' | 'network'; export interface SearchRaceWinner { source: SearchRaceSource; result: T; durationMs: number; } export interface SearchRaceRunner { source: SearchRaceSource; run: () => Promise; } /** * Run search backends in parallel. The first non-null result wins; one runner * failing does not reject until every runner has failed or returned null. */ export async function raceSearchSources( runners: SearchRaceRunner[], isStale: () => boolean, ): Promise | null> { if (runners.length === 0 || isStale()) return null; return new Promise((resolve, reject) => { let pending = runners.length; let settled = false; const errors: unknown[] = []; const onRunnerDone = () => { pending -= 1; if (!settled && pending === 0) { if (errors.length > 0) reject(errors[0]); else resolve(null); } }; for (const { source, run } of runners) { const t0 = performance.now(); void run() .then(result => { if (settled) return; if (isStale()) { onRunnerDone(); return; } if (result != null) { settled = true; resolve({ source, result, durationMs: Math.round(performance.now() - t0), }); return; } onRunnerDone(); }) .catch(err => { if (settled) return; if (isStale()) { onRunnerDone(); return; } errors.push(err); onRunnerDone(); }); } }); } export function searchResultsHaveHits(results: SearchResults): boolean { return results.artists.length > 0 || results.albums.length > 0 || results.songs.length > 0; } export interface LiveSearchRaceSettled { winner: SearchRaceSource; localMs: number; networkMs: number; localHits: string; networkHits: string; localResult: SearchResults | null; networkResult: SearchResults | null; } function hitCounts(r: SearchResults): string { return `${r.artists.length}/${r.albums.length}/${r.songs.length}`; } function emptySearchResults(): SearchResults { return { artists: [], albums: [], songs: [] }; } function resultOrEmpty(result: SearchResults | null): SearchResults { return result ?? emptySearchResults(); } /** * Live Search race: first backend with hits wins; empty waits for the other. * `onSettled` fires when both runners finish and includes both payloads for merge. */ export async function raceLiveSearch( localRun: () => Promise, networkRun: () => Promise, isStale: () => boolean, onSettled?: (meta: LiveSearchRaceSettled) => void, ): Promise | null> { if (isStale()) return null; return new Promise((resolve, reject) => { let settled = false; let resolvedWinner: SearchRaceSource | null = null; let localResult: SearchResults | null = null; let networkResult: SearchResults | null = null; let localDone = false; let networkDone = false; let localMs = 0; let networkMs = 0; let raceNotified = false; const errors: unknown[] = []; const notifySettled = () => { if (raceNotified || !localDone || !networkDone || !onSettled) return; raceNotified = true; const local = resultOrEmpty(localResult); const network = resultOrEmpty(networkResult); const winner = resolvedWinner ?? (searchResultsHaveHits(network) ? 'network' : searchResultsHaveHits(local) ? 'local' : networkResult ? 'network' : 'local'); onSettled({ winner, localMs, networkMs, localHits: hitCounts(local), networkHits: hitCounts(network), localResult, networkResult, }); }; const resolveWinner = (source: SearchRaceSource, result: SearchResults, durationMs: number) => { settled = true; resolvedWinner = source; resolve({ source, result, durationMs }); }; const maybeFinish = () => { if (settled || isStale()) return; if (localDone && localResult && searchResultsHaveHits(localResult)) { resolveWinner('local', localResult, localMs); if (networkDone) notifySettled(); return; } if (networkDone && networkResult && searchResultsHaveHits(networkResult)) { resolveWinner('network', networkResult, networkMs); if (localDone) notifySettled(); return; } if (!localDone || !networkDone) return; settled = true; if (networkResult && searchResultsHaveHits(networkResult)) { resolvedWinner = 'network'; resolve({ source: 'network', result: networkResult, durationMs: networkMs }); } else if (localResult && searchResultsHaveHits(localResult)) { resolvedWinner = 'local'; resolve({ source: 'local', result: localResult, durationMs: localMs }); } else if (networkResult) { resolvedWinner = 'network'; resolve({ source: 'network', result: networkResult, durationMs: networkMs }); } else if (localResult) { resolvedWinner = 'local'; resolve({ source: 'local', result: localResult, durationMs: localMs }); } else if (errors.length > 0) { reject(errors[0]); } else { resolve(null); } notifySettled(); }; const localT0 = performance.now(); void localRun() .then(result => { localMs = Math.round(performance.now() - localT0); localResult = result; localDone = true; maybeFinish(); notifySettled(); }) .catch(err => { errors.push(err); localDone = true; maybeFinish(); notifySettled(); }); const networkT0 = performance.now(); void networkRun() .then(result => { networkMs = Math.round(performance.now() - networkT0); networkResult = result; networkDone = true; maybeFinish(); notifySettled(); }) .catch(err => { const name = err instanceof Error ? err.name : ''; if (name === 'CanceledError' || name === 'AbortError') { networkDone = true; maybeFinish(); notifySettled(); return; } errors.push(err); networkDone = true; maybeFinish(); notifySettled(); }); }); }