music indexer: index track lyrics (.lrc/.txt sidecars + embedded)

Each track's lyrics are resolved and cached at cache/<rel>/lyrics/<file>.<lrc|txt>,
with the format recorded as `lyrics: 'lrc'|'txt'` on the meta.tracks entry.

Precedence: external "<base>.lrc" > external "<base>.txt" > embedded tag
(lyrics / lyrics-<lang> / unsyncedlyrics — ffprobe now reads all format tags).
Content that contains [mm:ss] lines is stored as lrc even from a .txt/embedded
source. Only track-matching sidecars affect the version signature (a stray
notes.txt is ignored). Lyrics dir is wiped+regenerated per rebuild; new
`lyricsIndexed` counter.

Served by GET /api/music/lyrics?path=<rel>&file=<track> (text/plain +
X-Lyrics-Format header, ETag=<v>, 304, 404 when none).

Verified end-to-end: external .lrc wins over embedded; embedded → plain txt;
unmatched .txt ignored. MUSIC_API.md documents the field + endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:51:58 +00:00
co-authored by Claude Opus 4.8
parent 10d7b6a425
commit b3a4da4b97
3 changed files with 129 additions and 17 deletions
+20
View File
@@ -13,6 +13,7 @@ import {
coverFilePath,
discographyFilePath,
posterFilePath,
lyricsFilePath,
onIndexProgress,
buildReport,
} from './indexer';
@@ -38,6 +39,7 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
// GET /lyrics?path=<rel>&file=<track> track lyrics text (X-Lyrics-Format: lrc|txt). ETag: <v>; 304. 404 if none.
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
// disco:true) = { artist, albums: { "<[year] album folder>":
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
@@ -166,6 +168,24 @@ const server = Bun.serve({
return new Response(Bun.file(posterPath), { headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) } });
}
// Track lyrics (external .lrc/.txt sidecar or embedded, cached during indexing). Try synced first.
if (url.pathname === '/lyrics') {
const rel = url.searchParams.get('path');
const file = url.searchParams.get('file');
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
for (const fmt of ['lrc', 'txt'] as const) {
const p = lyricsFilePath(rel, file, fmt);
if (p && (await Bun.file(p).exists())) {
const v = await albumVersion(rel);
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
return new Response(Bun.file(p), {
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Lyrics-Format': fmt, ...(v ? { ETag: v } : {}) },
});
}
}
return new Response('Not found', { status: 404 });
}
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
const rel = url.searchParams.get('path');
if (rel === null) return new Response('path is required', { status: 400 });