// Lyrics orchestrator — local-first lookup with an in-flight Map for request // deduplication and a persistent cache for embedded/online results. Source order: // sidecar → embedded → cache → xlrcdb → lrclib. import Constants from 'expo-constants'; import { clearLyricsCache, deleteLyricsCache, getLyricsCache, putLyricsCache } from '@/db/lyricsQueries'; import { sha1Hex } from '@/lib/hash'; import type { Track } from '@/types/audio'; import { resolveEmbeddedLyrics, resolveSidecarLyrics } from './local'; import type { LyricsLookupResult, LyricsPayload, LyricsTrackQuery } from './types'; import { resolveLyricsWithDependencies, resultFromLyricsCache } from './resolver'; import { LrclibLookupCoordinator, createLrclibClientConfig, normalizeLrclibMetadataText } from '@/services/lyrics/lrclib'; import { XlrcdbLookupCoordinator, createXlrcdbClientConfig } from '@/services/lyrics/xlrcdb'; import { safeNormalize } from '@/services/lyrics/unicode'; import { CacheInvalidationGate } from '@/lib/cacheInvalidation'; export interface GetLyricsOptions { forceRefresh?: boolean; onlineEnabled?: boolean; } const APP_VERSION = Constants.expoConfig?.version ?? '0.1.0'; let xlrcdbProvider: XlrcdbLookupCoordinator | null = null; let lrclibProvider: LrclibLookupCoordinator | null = null; const cacheGate = new CacheInvalidationGate(); function getXlrcdb(): XlrcdbLookupCoordinator { if (!xlrcdbProvider) xlrcdbProvider = new XlrcdbLookupCoordinator(createXlrcdbClientConfig()); return xlrcdbProvider; } function getLrclib(): LrclibLookupCoordinator { if (!lrclibProvider) lrclibProvider = new LrclibLookupCoordinator(createLrclibClientConfig({ appVersion: APP_VERSION })); return lrclibProvider; } function normalizeDurationSeconds(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.round(value); return null; } /** Stable, Hermes-safe signature normalize — only needs consistency across runs. */ function normalizeSignatureText(value: string): string { return safeNormalize(value.trim(), 'NFKC').toLocaleLowerCase().replace(/\s+/g, ' ').trim(); } function createMetadataSignature(query: LyricsTrackQuery): string { const title = normalizeSignatureText(query.title); const artist = normalizeSignatureText(query.artist); const album = normalizeSignatureText(query.album ?? ''); const duration = normalizeDurationSeconds(query.durationSeconds) ?? -1; return sha1Hex(`${title}${artist}${album}${duration}`); } /** Builds a lookup query from the currently-playing track. */ export function buildLyricsQuery(track: Track | null): LyricsTrackQuery | null { if (!track) return null; const path = track.path?.trim(); const title = normalizeLrclibMetadataText(track.title); const artist = normalizeLrclibMetadataText(track.artist); if (!path || !title || !artist) return null; return { path, title, artist, album: normalizeLrclibMetadataText(track.album) ?? undefined, durationSeconds: normalizeDurationSeconds(track.duration) ?? undefined, }; } /** * Reads only the persisted lyrics cache for a track. This deliberately skips * sidecar/embedded scanning and every provider so passive UI can remain * strictly zero-network and zero-media-I/O. */ export async function peekCachedLyricsForTrack( track: Track | null ): Promise { const query = buildLyricsQuery(track); if (!query) return null; const cache = await getLyricsCache(query.path, createMetadataSignature(query)); if (!cache || cache.status !== 'hit') return null; const result = resultFromLyricsCache(cache); return result?.status === 'hit' ? result : null; } async function cacheHit(trackPath: string, signature: string, payload: LyricsPayload, generation: number): Promise { await cacheGate.enqueue(async () => { if (!cacheGate.isCurrent(generation)) return; if (!cacheGate.isCurrent(generation)) return; await putLyricsCache({ trackPath, metadataSignature: signature, status: 'hit', source: payload.source, provider: payload.provider, format: payload.format, plainLyrics: payload.plainLyrics, syncedLyrics: payload.syncedLyrics, syncedLines: payload.syncedLines, }); }).catch(() => { /* cache write failure is non-fatal */ }); } async function cacheNotFound(trackPath: string, signature: string, generation: number): Promise { await cacheGate.enqueue(async () => { if (!cacheGate.isCurrent(generation)) return; if (!cacheGate.isCurrent(generation)) return; await putLyricsCache({ trackPath, metadataSignature: signature, status: 'not_found', source: 'xlrcdb', provider: 'xlrcdb', format: null, plainLyrics: null, syncedLyrics: null, syncedLines: [], }); }).catch(() => { /* cache write failure is non-fatal */ }); } const inflight = new Map>(); /** * Resolves lyrics for a track. Local sources are checked before cache and online; * hits are persisted so passive/revisited UI can render without media I/O. */ export function getLyricsForTrack(query: LyricsTrackQuery, options: GetLyricsOptions = {}): Promise { const onlineEnabled = options.onlineEnabled ?? true; const forceRefresh = Boolean(options.forceRefresh); const signature = createMetadataSignature(query); const key = `${query.path}${signature}${forceRefresh ? 1 : 0}${onlineEnabled ? 1 : 0}`; const existing = inflight.get(key); if (existing) return existing; const generation = cacheGate.capture(); const task = resolveLyrics(query, signature, { forceRefresh, onlineEnabled }, generation).finally(() => { if (inflight.get(key) === task) inflight.delete(key); }); inflight.set(key, task); return task; } async function resolveLyrics( query: LyricsTrackQuery, signature: string, options: { forceRefresh: boolean; onlineEnabled: boolean }, generation: number ): Promise { return resolveLyricsWithDependencies(query, options, { resolveSidecar: resolveSidecarLyrics, resolveEmbedded: resolveEmbeddedLyrics, getCache: () => getLyricsCache(query.path, signature), deleteCache: () => deleteLyricsCache(query.path).catch(() => undefined), cacheHit: (payload) => cacheHit(query.path, signature, payload, generation), cacheNotFound: () => cacheNotFound(query.path, signature, generation), lookupXlrcdb: (lookupQuery, forceRefresh) => getXlrcdb().lookup(lookupQuery, signature, { forceRefresh }), lookupLrclib: (lookupQuery, forceRefresh) => getLrclib().lookup(lookupQuery, signature, { forceRefresh }), }); } /** Clears persisted and in-memory lyrics work without allowing older requests to repopulate it. */ export async function clearAllLyricsCache(): Promise { inflight.clear(); await cacheGate.invalidate(async () => { await clearLyricsCache(); }); }