mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
lyrics support + lookup
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// LRCLIB provider — ported from desktop (astra/src/main/services/lyricsLrclib.ts).
|
||||
// Two-stage lookup: exact metadata /get, then a scored /search fallback. Uses
|
||||
// global fetch + AbortController (both available under RN). Unicode
|
||||
// normalization goes through the Hermes-safe helpers in ./unicode.
|
||||
|
||||
import { createLyricsPayload, normalizeLyricsText, parseLrcSyncedLines } from '@/lyrics/parsing';
|
||||
import type { LyricsPayload, LyricsTrackQuery } from '@/lyrics/types';
|
||||
import { COMBINING_MARKS_RE, CONTROL_CHARS_RE, NON_ALNUM_RE, safeNormalize } from './unicode';
|
||||
|
||||
export const LRCLIB_GET_URL = 'https://lrclib.net/api/get';
|
||||
export const LRCLIB_SEARCH_URL = 'https://lrclib.net/api/search';
|
||||
export const LRCLIB_PROJECT_URL = 'https://github.com/Boof2015/astra';
|
||||
export const LRCLIB_REQUEST_TIMEOUT_MS = 15_000;
|
||||
export const LRCLIB_PROVIDER_COOLDOWN_MS = 60_000;
|
||||
|
||||
const UNKNOWN_APP_VERSION = 'unknown';
|
||||
|
||||
export interface LrclibClientConfig {
|
||||
appVersion: string;
|
||||
requestTimeoutMs: number;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export type LrclibLookupResult =
|
||||
| { status: 'hit'; lyrics: LyricsPayload }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'provider_unavailable' }
|
||||
| { status: 'transient_error'; message: string; code?: string };
|
||||
|
||||
type FetchJsonResult<T> =
|
||||
| { kind: 'ok'; payload: T }
|
||||
| { kind: 'http_error'; status: number }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'network_error' }
|
||||
| { kind: 'invalid_payload' };
|
||||
|
||||
interface ScoredLrclibCandidate {
|
||||
entry: Record<string, unknown>;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function normalizeLrclibAppVersion(value: string | null | undefined): string {
|
||||
const normalized = typeof value === 'string' ? value.trim().replace(/\s+/g, '-') : '';
|
||||
return normalized.length > 0 ? normalized : UNKNOWN_APP_VERSION;
|
||||
}
|
||||
|
||||
export function createLrclibClientConfig(options: {
|
||||
appVersion: string;
|
||||
requestTimeoutMs?: number;
|
||||
now?: () => number;
|
||||
}): LrclibClientConfig {
|
||||
return {
|
||||
appVersion: normalizeLrclibAppVersion(options.appVersion),
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? LRCLIB_REQUEST_TIMEOUT_MS,
|
||||
now: options.now ?? Date.now,
|
||||
};
|
||||
}
|
||||
|
||||
function createLrclibClientHeaders(config: Pick<LrclibClientConfig, 'appVersion'>): Record<string, string> {
|
||||
const client = `Astra/${normalizeLrclibAppVersion(config.appVersion)} (${LRCLIB_PROJECT_URL})`;
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Lrclib-Client': client,
|
||||
'User-Agent': client,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLrclibMetadataText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.replace(CONTROL_CHARS_RE, ' ').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeMatchKey(value: string): string {
|
||||
return safeNormalize(value, 'NFKD')
|
||||
.replace(COMBINING_MARKS_RE, '')
|
||||
.toLocaleLowerCase()
|
||||
.replace(NON_ALNUM_RE, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return Math.round(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return Math.round(parsed);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scoreMatch(candidate: string | null, target: string): number {
|
||||
if (!candidate) return 0;
|
||||
const normalizedCandidate = normalizeMatchKey(candidate);
|
||||
const normalizedTarget = normalizeMatchKey(target);
|
||||
if (!normalizedCandidate || !normalizedTarget) return 0;
|
||||
if (normalizedCandidate === normalizedTarget) return 100;
|
||||
if (normalizedCandidate.startsWith(normalizedTarget) || normalizedTarget.startsWith(normalizedCandidate)) return 60;
|
||||
if (normalizedCandidate.includes(normalizedTarget) || normalizedTarget.includes(normalizedCandidate)) return 30;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isTransientHttpStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function fetchResultToTransientCode(prefix: string, result: FetchJsonResult<unknown>): string {
|
||||
if (result.kind === 'timeout') return `${prefix}_timeout`;
|
||||
if (result.kind === 'network_error') return `${prefix}_network_error`;
|
||||
if (result.kind === 'invalid_payload') return `${prefix}_invalid_payload`;
|
||||
if (result.kind === 'http_error') return `${prefix}_http_${result.status}`;
|
||||
return `${prefix}_error`;
|
||||
}
|
||||
|
||||
async function fetchLrclibJson<T>(url: string, config: LrclibClientConfig): Promise<FetchJsonResult<T>> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: createLrclibClientHeaders(config),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { kind: 'http_error', status: response.status };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
return { kind: 'ok', payload: payload as T };
|
||||
} catch {
|
||||
return { kind: 'invalid_payload' };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { kind: 'timeout' };
|
||||
}
|
||||
return { kind: 'network_error' };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLrclibEntry(entry: Record<string, unknown>): LyricsPayload | null {
|
||||
const plainLyrics = normalizeLyricsText(entry.plainLyrics) ?? normalizeLyricsText(entry.plain_lyrics);
|
||||
const syncedRaw = normalizeLyricsText(entry.syncedLyrics) ?? normalizeLyricsText(entry.synced_lyrics);
|
||||
const syncedLines = syncedRaw ? parseLrcSyncedLines(syncedRaw) : [];
|
||||
return createLyricsPayload('lrclib', 'lrclib', syncedLines.length > 0 ? 'lrc' : 'plain', plainLyrics, syncedRaw, syncedLines);
|
||||
}
|
||||
|
||||
function scoreSearchEntry(entry: Record<string, unknown>, query: LyricsTrackQuery): number {
|
||||
const titleValue = normalizeLrclibMetadataText(entry.trackName) ?? normalizeLrclibMetadataText(entry.track_name);
|
||||
const artistValue = normalizeLrclibMetadataText(entry.artistName) ?? normalizeLrclibMetadataText(entry.artist_name);
|
||||
const albumValue = normalizeLrclibMetadataText(entry.albumName) ?? normalizeLrclibMetadataText(entry.album_name);
|
||||
const durationValue = normalizeDurationSeconds(entry.duration);
|
||||
|
||||
const titleScore = scoreMatch(titleValue, query.title);
|
||||
const artistScore = scoreMatch(artistValue, query.artist);
|
||||
if (titleScore === 0 || artistScore === 0) return 0;
|
||||
|
||||
let score = titleScore * 5 + artistScore * 4;
|
||||
if (query.album) {
|
||||
score += scoreMatch(albumValue, query.album) * 2;
|
||||
}
|
||||
|
||||
const queryDuration = normalizeDurationSeconds(query.durationSeconds);
|
||||
if (queryDuration !== null && durationValue !== null) {
|
||||
const delta = Math.abs(queryDuration - durationValue);
|
||||
if (delta <= 2) {
|
||||
score += 120;
|
||||
} else if (delta <= 5) {
|
||||
score += 80;
|
||||
} else if (delta <= 10) {
|
||||
score += 40;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
async function lookupLrclibByMetadata(query: LyricsTrackQuery, config: LrclibClientConfig): Promise<LrclibLookupResult> {
|
||||
const params = new URLSearchParams({
|
||||
track_name: query.title,
|
||||
artist_name: query.artist,
|
||||
});
|
||||
const album = normalizeLrclibMetadataText(query.album);
|
||||
if (album) {
|
||||
params.set('album_name', album);
|
||||
}
|
||||
const duration = normalizeDurationSeconds(query.durationSeconds);
|
||||
if (duration !== null) {
|
||||
params.set('duration', String(duration));
|
||||
}
|
||||
|
||||
const response = await fetchLrclibJson<Record<string, unknown>>(`${LRCLIB_GET_URL}?${params.toString()}`, config);
|
||||
if (response.kind === 'http_error') {
|
||||
if (response.status === 404) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
if (isTransientHttpStatus(response.status)) {
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message: 'LRCLIB metadata lookup failed due to a transient HTTP error.',
|
||||
code: fetchResultToTransientCode('lrclib_get', response),
|
||||
};
|
||||
}
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (response.kind !== 'ok') {
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message: 'LRCLIB metadata lookup failed due to a transient network error.',
|
||||
code: fetchResultToTransientCode('lrclib_get', response),
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.payload || typeof response.payload !== 'object' || Array.isArray(response.payload)) {
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message: 'LRCLIB metadata lookup returned an invalid payload.',
|
||||
code: 'lrclib_get_invalid_payload',
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseLrclibEntry(response.payload);
|
||||
if (!parsed) return { status: 'not_found' };
|
||||
return { status: 'hit', lyrics: parsed };
|
||||
}
|
||||
|
||||
async function lookupLrclibBySearch(query: LyricsTrackQuery, config: LrclibClientConfig): Promise<LrclibLookupResult> {
|
||||
const searchTerm = [query.title, query.artist, query.album ?? '']
|
||||
.map((value) => normalizeLrclibMetadataText(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' ');
|
||||
if (!searchTerm) return { status: 'not_found' };
|
||||
|
||||
const params = new URLSearchParams({ q: searchTerm });
|
||||
const response = await fetchLrclibJson<unknown[]>(`${LRCLIB_SEARCH_URL}?${params.toString()}`, config);
|
||||
if (response.kind === 'http_error') {
|
||||
if (response.status === 404) return { status: 'not_found' };
|
||||
if (isTransientHttpStatus(response.status)) {
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message: 'LRCLIB search lookup failed due to a transient HTTP error.',
|
||||
code: fetchResultToTransientCode('lrclib_search', response),
|
||||
};
|
||||
}
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (response.kind !== 'ok') {
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message: 'LRCLIB search lookup failed due to a transient network error.',
|
||||
code: fetchResultToTransientCode('lrclib_search', response),
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(response.payload)) {
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message: 'LRCLIB search lookup returned an invalid payload.',
|
||||
code: 'lrclib_search_invalid_payload',
|
||||
};
|
||||
}
|
||||
|
||||
const candidates: ScoredLrclibCandidate[] = [];
|
||||
for (const item of response.payload) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const score = scoreSearchEntry(entry, query);
|
||||
if (score <= 0) continue;
|
||||
candidates.push({ entry, score });
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => right.score - left.score);
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseLrclibEntry(candidate.entry);
|
||||
if (!parsed) continue;
|
||||
return { status: 'hit', lyrics: parsed };
|
||||
}
|
||||
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
async function lookupLrclibRaw(query: LyricsTrackQuery, config: LrclibClientConfig): Promise<LrclibLookupResult> {
|
||||
const metadataLookup = await lookupLrclibByMetadata(query, config);
|
||||
if (metadataLookup.status === 'hit' || metadataLookup.status === 'transient_error') {
|
||||
return metadataLookup;
|
||||
}
|
||||
return lookupLrclibBySearch(query, config);
|
||||
}
|
||||
|
||||
export class LrclibLookupCoordinator {
|
||||
private readonly config: LrclibClientConfig;
|
||||
private cooldownUntil = 0;
|
||||
private readonly inFlightLookups = new Map<string, Promise<LrclibLookupResult>>();
|
||||
|
||||
constructor(config: LrclibClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
isCoolingDown(): boolean {
|
||||
return this.config.now() < this.cooldownUntil;
|
||||
}
|
||||
|
||||
async lookup(
|
||||
query: LyricsTrackQuery,
|
||||
lookupKey: string,
|
||||
options: { forceRefresh?: boolean } = {}
|
||||
): Promise<LrclibLookupResult> {
|
||||
const forceRefresh = Boolean(options.forceRefresh);
|
||||
if (!forceRefresh && this.isCoolingDown()) {
|
||||
return { status: 'provider_unavailable' };
|
||||
}
|
||||
|
||||
const result = await this.lookupDeduped(query, lookupKey);
|
||||
if (result.status === 'transient_error') {
|
||||
if (!forceRefresh) {
|
||||
this.cooldownUntil = this.config.now() + LRCLIB_PROVIDER_COOLDOWN_MS;
|
||||
return { status: 'provider_unavailable' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
this.cooldownUntil = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
private lookupDeduped(query: LyricsTrackQuery, lookupKey: string): Promise<LrclibLookupResult> {
|
||||
const existing = this.inFlightLookups.get(lookupKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const lookup = lookupLrclibRaw(query, this.config).finally(() => {
|
||||
if (this.inFlightLookups.get(lookupKey) === lookup) {
|
||||
this.inFlightLookups.delete(lookupKey);
|
||||
}
|
||||
});
|
||||
this.inFlightLookups.set(lookupKey, lookup);
|
||||
return lookup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Hermes-safe Unicode helpers for the lyrics providers. Desktop uses
|
||||
// String.prototype.normalize and `\p{…}` regex property escapes freely; RN's
|
||||
// Hermes engine may lack full ICU normalization or property-escape support on a
|
||||
// given build, and an unsupported `\p{…}` regex *literal* is a parse error that
|
||||
// would break the whole bundle. So we (a) wrap normalize in try/catch and
|
||||
// (b) build any property-escape regex with `new RegExp` inside try/catch,
|
||||
// falling back to an explicit-range regex (built from \u escapes) when the
|
||||
// engine rejects it.
|
||||
|
||||
export function safeNormalize(value: string, form: 'NFC' | 'NFD' | 'NFKC' | 'NFKD'): string {
|
||||
try {
|
||||
return value.normalize(form);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function makeUnicodeRegex(pattern: string, flags: string, fallbackPattern: string): RegExp {
|
||||
try {
|
||||
return new RegExp(pattern, flags);
|
||||
} catch {
|
||||
// The property-escape form was rejected; the fallback uses only \u ranges.
|
||||
return new RegExp(fallbackPattern, flags.replace('u', ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Combining marks (\p{M}). Fallback covers the common combining-mark blocks.
|
||||
export const COMBINING_MARKS_RE = makeUnicodeRegex(
|
||||
'\\p{M}+',
|
||||
'gu',
|
||||
'[\\u0300-\\u036f\\u1ab0-\\u1aff\\u1dc0-\\u1dff\\u20d0-\\u20ff\\ufe20-\\ufe2f]+'
|
||||
);
|
||||
|
||||
// Anything that is NOT a letter or number ([^\p{L}\p{N}]). Fallback keeps ASCII
|
||||
// alphanumerics plus the common Latin/Greek/Cyrillic/CJK/Kana/Hangul ranges and
|
||||
// collapses the rest (punctuation, symbols, whitespace) to a single space.
|
||||
export const NON_ALNUM_RE = makeUnicodeRegex(
|
||||
'[^\\p{L}\\p{N}]+',
|
||||
'gu',
|
||||
'[^0-9A-Za-z\\u00c0-\\u024f\\u0370-\\u03ff\\u0400-\\u04ff\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af\\uff00-\\uffef]+'
|
||||
);
|
||||
|
||||
// Control characters (\p{Cc}). Fallback covers C0 + DEL + C1.
|
||||
export const CONTROL_CHARS_RE = makeUnicodeRegex(
|
||||
'\\p{Cc}+',
|
||||
'gu',
|
||||
'[\\u0000-\\u001f\\u007f-\\u009f]+'
|
||||
);
|
||||
@@ -0,0 +1,179 @@
|
||||
// XLRCDB provider — ported from desktop (astra/src/main/services/lyricsXlrcdb.ts).
|
||||
// Delegates artist/title/duration matching to the `@boof2015/xlrc` package's
|
||||
// `lookup()` against the static GitHub-Pages dataset, wrapping RN's global fetch
|
||||
// with an AbortController timeout. XLRCDB requires a duration to match.
|
||||
|
||||
import {
|
||||
lookup as lookupXlrcdb,
|
||||
serializeXLRC,
|
||||
type FetchLike,
|
||||
type FetchResponseLike,
|
||||
type XLRCFile,
|
||||
} from '@boof2015/xlrc';
|
||||
import { createLyricsPayload } from '@/lyrics/parsing';
|
||||
import type { LyricsPayload, LyricsTrackQuery } from '@/lyrics/types';
|
||||
|
||||
export const XLRCDB_SOURCE_URL = 'https://boof2015.github.io/xlrcdb';
|
||||
export const XLRCDB_REQUEST_TIMEOUT_MS = 15_000;
|
||||
export const XLRCDB_PROVIDER_COOLDOWN_MS = 60_000;
|
||||
|
||||
export interface XlrcdbClientConfig {
|
||||
sourceUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export type XlrcdbLookupResult =
|
||||
| { status: 'hit'; lyrics: LyricsPayload }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'skipped'; reason: 'duration_missing' }
|
||||
| { status: 'provider_unavailable' }
|
||||
| { status: 'transient_error'; message: string; code?: string };
|
||||
|
||||
export function createXlrcdbClientConfig(
|
||||
options: { sourceUrl?: string; requestTimeoutMs?: number; now?: () => number } = {}
|
||||
): XlrcdbClientConfig {
|
||||
return {
|
||||
sourceUrl: (options.sourceUrl ?? XLRCDB_SOURCE_URL).replace(/\/+$/u, ''),
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? XLRCDB_REQUEST_TIMEOUT_MS,
|
||||
now: options.now ?? Date.now,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return Math.round(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return Math.round(parsed);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createXlrcdbPayload(file: XLRCFile): LyricsPayload | null {
|
||||
return createLyricsPayload('xlrcdb', 'xlrcdb', 'xlrc', null, serializeXLRC(file), []);
|
||||
}
|
||||
|
||||
function createTimeoutFetch(
|
||||
config: XlrcdbClientConfig,
|
||||
setFailureKind: (kind: 'timeout' | 'network_error') => void
|
||||
): FetchLike {
|
||||
return async (input: string): Promise<FetchResponseLike> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
|
||||
|
||||
try {
|
||||
return await fetch(input, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
setFailureKind('timeout');
|
||||
} else {
|
||||
setFailureKind('network_error');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toTransientCode(
|
||||
reason: 'fetch_error' | 'parse_error',
|
||||
failureKind: 'timeout' | 'network_error' | null
|
||||
): string {
|
||||
if (reason === 'parse_error') return 'xlrcdb_parse_error';
|
||||
if (failureKind === 'timeout') return 'xlrcdb_timeout';
|
||||
if (failureKind === 'network_error') return 'xlrcdb_network_error';
|
||||
return 'xlrcdb_fetch_error';
|
||||
}
|
||||
|
||||
async function lookupXlrcdbRaw(query: LyricsTrackQuery, config: XlrcdbClientConfig): Promise<XlrcdbLookupResult> {
|
||||
const duration = normalizeDurationSeconds(query.durationSeconds);
|
||||
if (duration === null) {
|
||||
return { status: 'skipped', reason: 'duration_missing' };
|
||||
}
|
||||
|
||||
let failureKind: 'timeout' | 'network_error' | null = null;
|
||||
const result = await lookupXlrcdb({
|
||||
artist: query.artist,
|
||||
title: query.title,
|
||||
length: duration,
|
||||
source: config.sourceUrl,
|
||||
fetch: createTimeoutFetch(config, (kind) => {
|
||||
failureKind = kind;
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
const lyrics = createXlrcdbPayload(result.lyrics);
|
||||
return lyrics ? { status: 'hit', lyrics } : { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (result.reason === 'artist_not_found' || result.reason === 'track_not_found') {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'transient_error',
|
||||
message:
|
||||
result.reason === 'parse_error'
|
||||
? 'XLRCDB lookup returned lyrics that could not be parsed.'
|
||||
: 'XLRCDB lookup failed due to a transient network error.',
|
||||
code: toTransientCode(result.reason, failureKind),
|
||||
};
|
||||
}
|
||||
|
||||
export class XlrcdbLookupCoordinator {
|
||||
private readonly config: XlrcdbClientConfig;
|
||||
private cooldownUntil = 0;
|
||||
private readonly inFlightLookups = new Map<string, Promise<XlrcdbLookupResult>>();
|
||||
|
||||
constructor(config: XlrcdbClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
isCoolingDown(): boolean {
|
||||
return this.config.now() < this.cooldownUntil;
|
||||
}
|
||||
|
||||
async lookup(
|
||||
query: LyricsTrackQuery,
|
||||
lookupKey: string,
|
||||
options: { forceRefresh?: boolean } = {}
|
||||
): Promise<XlrcdbLookupResult> {
|
||||
const forceRefresh = Boolean(options.forceRefresh);
|
||||
if (!forceRefresh && this.isCoolingDown()) {
|
||||
return { status: 'provider_unavailable' };
|
||||
}
|
||||
|
||||
const result = await this.lookupDeduped(query, lookupKey);
|
||||
if (result.status === 'transient_error') {
|
||||
if (!forceRefresh) {
|
||||
this.cooldownUntil = this.config.now() + XLRCDB_PROVIDER_COOLDOWN_MS;
|
||||
return { status: 'provider_unavailable' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.status !== 'skipped') {
|
||||
this.cooldownUntil = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private lookupDeduped(query: LyricsTrackQuery, lookupKey: string): Promise<XlrcdbLookupResult> {
|
||||
const existing = this.inFlightLookups.get(lookupKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const lookup = lookupXlrcdbRaw(query, this.config).finally(() => {
|
||||
if (this.inFlightLookups.get(lookupKey) === lookup) {
|
||||
this.inFlightLookups.delete(lookupKey);
|
||||
}
|
||||
});
|
||||
this.inFlightLookups.set(lookupKey, lookup);
|
||||
return lookup;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user