lyrics support + lookup

This commit is contained in:
Boof2015
2026-07-08 23:16:10 -04:00
parent acc6bdf3eb
commit 6ccc73401c
19 changed files with 2478 additions and 29 deletions
+348
View File
@@ -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;
}
}
+48
View File
@@ -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]+'
);
+179
View File
@@ -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;
}
}