Files
musicshare/apps/api/src/services/lyrics.ts
T
chrisandClaude Opus 5.5 b705bfcce4 Fix a gap under Friends listening, and lyrics failing while LRCLIB's exact lookup is overloaded
The feed's itemLayoutAnimation left the cards behind when the header above them shrank
(your own card's lyrics loading). LRCLIB rate-limits /api/get on its own; fall back to search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:16:18 -04:00

84 lines
3.6 KiB
TypeScript

import { eq } from 'drizzle-orm';
import { parseLrc, type LyricsDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { env } from '../env';
import { HttpError, fetchJson, normalize, primaryArtist } from '../lib/http';
/** Look again for songs that had none after this long; lyrics get added to LRCLIB all the time. */
const RETRY_MISS_MS = 7 * 86_400_000;
/** A search result has to be this close in length to count as the same recording. */
const DURATION_SLACK_S = 4;
interface LrclibRecord {
id: number;
trackName: string;
artistName: string;
duration: number | null;
instrumental: boolean;
plainLyrics: string | null;
syncedLyrics: string | null;
}
type Row = typeof schema.lyrics.$inferSelect;
/** The track's lyrics, from our cache or LRCLIB. Null when there are none (or lyrics are turned off). */
export async function getLyrics(trackId: string): Promise<LyricsDTO | null> {
if (!env.LRCLIB_URL) return null;
const [track] = await db.select().from(schema.tracks).where(eq(schema.tracks.id, trackId));
if (!track) throw new HttpError(404, 'Track not found');
let [row] = await db.select().from(schema.lyrics).where(eq(schema.lyrics.trackId, trackId));
const stale = row && !row.sourceUrl && Date.now() - row.fetchedAt.getTime() > RETRY_MISS_MS;
if (!row || stale) {
const found = await lookup(track);
const values = {
synced: found?.syncedLyrics || null,
plain: found?.plainLyrics || null,
instrumental: found?.instrumental ?? false,
sourceUrl: found ? `${env.LRCLIB_URL}/api/get/${found.id}` : null,
fetchedAt: new Date(),
};
[row] = await db
.insert(schema.lyrics)
.values({ trackId, ...values })
.onConflictDoUpdate({ target: schema.lyrics.trackId, set: values })
.returning();
}
return toDTO(row!);
}
function toDTO(row: Row): LyricsDTO | null {
if (!row.sourceUrl || (!row.synced && !row.plain && !row.instrumental)) return null;
const synced = row.synced ? parseLrc(row.synced) : null;
return {
synced: synced?.length ? synced : null,
plain: row.plain,
instrumental: row.instrumental,
source: { name: 'LRCLIB', url: 'https://lrclib.net' },
};
}
async function lookup(track: { title: string; artist: string; album: string | null; durationMs: number | null }) {
const artist = primaryArtist(track.artist);
const headers = { 'user-agent': 'musicshare (https://musicshare.binarygnome.com)' };
// Exact lookup needs the length; LRCLIB matches it within a couple of seconds.
if (track.durationMs) {
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
if (track.album) params.set('album_name', track.album);
// Not found, or LRCLIB's exact lookup is overloaded (it rate-limits /api/get separately): try the search.
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch(() => null);
if (exact) return exact;
}
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
const results = (await fetchJson<LrclibRecord[]>(`${env.LRCLIB_URL}/api/search?${params}`, { headers })) ?? [];
const title = normalize(track.title);
const matches = results.filter(
(r) =>
normalize(r.trackName) === title &&
normalize(r.artistName).includes(normalize(artist)) &&
(!track.durationMs || !r.duration || Math.abs(r.duration - track.durationMs / 1000) <= DURATION_SLACK_S),
);
// Prefer synced lyrics, then anything with words.
return matches.find((r) => r.syncedLyrics) ?? matches.find((r) => r.plainLyrics || r.instrumental) ?? null;
}