Files
astra-mobile/src/lyrics/lyrics.ts
T
2026-07-24 02:18:37 -04:00

178 lines
6.8 KiB
TypeScript

// 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<LyricsLookupResult | null> {
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<void> {
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<void> {
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<string, Promise<LyricsLookupResult>>();
/**
* 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<LyricsLookupResult> {
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<LyricsLookupResult> {
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<void> {
inflight.clear();
await cacheGate.invalidate(async () => {
await clearLyricsCache();
});
}