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
84 lines
3.6 KiB
TypeScript
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;
|
|
}
|