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 { 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(`${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(`${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; }