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:
+16
-2
@@ -96,7 +96,8 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
||||
"album": "Back in Black",
|
||||
"track": "1",
|
||||
"year": "1980",
|
||||
"durationSec": 312
|
||||
"durationSec": 312,
|
||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||
}
|
||||
// …
|
||||
],
|
||||
@@ -134,6 +135,19 @@ A compressed frame grab for a video (≤600px, same treatment as covers), taken
|
||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||
|
||||
#### 2.3.2 Lyrics
|
||||
|
||||
```
|
||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||
```
|
||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||
|
||||
Sources, in precedence order (indexed at build time): an external **`<track basename>.lrc`** > external
|
||||
**`<track basename>.txt`** > **embedded** lyrics in the audio tags (`lyrics` / `lyrics-<lang>` /
|
||||
`unsyncedlyrics`). A `.txt` (or embedded) whose text actually contains `[mm:ss]` lines is served as `lrc`.
|
||||
|
||||
### 2.4 Discography (artist album grouping)
|
||||
|
||||
For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release
|
||||
@@ -184,7 +198,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
||||
"running": true,
|
||||
"startedAt": 1785034701973, "finishedAt": null,
|
||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "discographies": 3,
|
||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||
"error": null
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -9,10 +9,12 @@ import { homedir } from 'node:os';
|
||||
// (relative to the Music root):
|
||||
//
|
||||
// DATA_PATH/music/cache/<rel>/meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist,
|
||||
// album, track, year, durationSec }],
|
||||
// videos?: [{ file, title, durationSec, width, height }] }
|
||||
// album, track, year, durationSec, lyrics?:'lrc'|'txt' }],
|
||||
// videos?: [{ file, title, durationSec, width, height, poster? }] }
|
||||
// (phone IndexMeta schema)
|
||||
// DATA_PATH/music/cache/<rel>/cover.jpg compressed (<=600px, jpeg q5)
|
||||
// DATA_PATH/music/cache/<rel>/posters/<videofile>.jpg video frame-grab thumbnails
|
||||
// DATA_PATH/music/cache/<rel>/lyrics/<trackfile>.<lrc|txt> external-sidecar or embedded lyrics
|
||||
//
|
||||
// A manifest (cache root) carries a per-album version `v` = hash of the album's source signature (track
|
||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||
@@ -44,6 +46,7 @@ export type IndexTrack = {
|
||||
track?: string;
|
||||
year?: string;
|
||||
durationSec?: number;
|
||||
lyrics?: 'lrc' | 'txt'; // available lyrics format ('lrc' = synced/timestamped); text via /lyrics. Absent = none.
|
||||
};
|
||||
export type IndexVideo = {
|
||||
file: string;
|
||||
@@ -105,6 +108,7 @@ export type IndexStatus = {
|
||||
videosIndexed: number;
|
||||
coversSaved: number;
|
||||
postersSaved: number;
|
||||
lyricsIndexed: number;
|
||||
discographies: number; // artist discography.json files written
|
||||
currentPath: string;
|
||||
error: string | null;
|
||||
@@ -149,6 +153,7 @@ const status: IndexStatus = {
|
||||
videosIndexed: 0,
|
||||
coversSaved: 0,
|
||||
postersSaved: 0,
|
||||
lyricsIndexed: 0,
|
||||
discographies: 0,
|
||||
currentPath: '',
|
||||
error: null,
|
||||
@@ -240,7 +245,7 @@ async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise
|
||||
return out;
|
||||
}
|
||||
|
||||
async function ffprobeTrack(absPath: string, file: string): Promise<IndexTrack> {
|
||||
async function ffprobeTrack(absPath: string, file: string): Promise<{ track: IndexTrack; lyricsText?: string }> {
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
@@ -249,8 +254,9 @@ async function ffprobeTrack(absPath: string, file: string): Promise<IndexTrack>
|
||||
'error',
|
||||
'-print_format',
|
||||
'json',
|
||||
// All format tags — so embedded lyrics (key varies: lyrics, lyrics-<lang>, unsyncedlyrics) come through.
|
||||
'-show_entries',
|
||||
'format=duration:format_tags=title,artist,album,album_artist,track,date',
|
||||
'format=duration:format_tags',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
@@ -264,18 +270,29 @@ async function ffprobeTrack(absPath: string, file: string): Promise<IndexTrack>
|
||||
for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val;
|
||||
const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined;
|
||||
const yearMatch = tags.date?.match(/\d{4}/);
|
||||
// Embedded lyrics: USLT/foobar keys — `lyrics`, `lyrics-<lang>`, `unsyncedlyrics`.
|
||||
let lyricsText: string | undefined;
|
||||
for (const [k, val] of Object.entries(tags)) {
|
||||
if (/^(lyrics|unsyncedlyrics)($|[-_])/.test(k) && val && val.trim()) {
|
||||
lyricsText = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
file,
|
||||
title: tags.title || undefined,
|
||||
artist: tags.artist || undefined,
|
||||
albumArtist: tags.album_artist || undefined,
|
||||
album: tags.album || undefined,
|
||||
track: tags.track || undefined,
|
||||
year: yearMatch ? yearMatch[0] : undefined,
|
||||
durationSec: Number.isFinite(durNum) ? durNum : undefined,
|
||||
track: {
|
||||
file,
|
||||
title: tags.title || undefined,
|
||||
artist: tags.artist || undefined,
|
||||
albumArtist: tags.album_artist || tags.albumartist || undefined,
|
||||
album: tags.album || undefined,
|
||||
track: tags.track || undefined,
|
||||
year: yearMatch ? yearMatch[0] : undefined,
|
||||
durationSec: Number.isFinite(durNum) ? durNum : undefined,
|
||||
},
|
||||
lyricsText,
|
||||
};
|
||||
} catch {
|
||||
return { file };
|
||||
return { track: { file } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +387,41 @@ async function generateVideoPoster(srcAbs: string, destAbs: string, durationSec?
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lyrics ──
|
||||
|
||||
const LRC_TIMESTAMP_RE = /^\s*\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]/m;
|
||||
const isLrc = (text: string): boolean => LRC_TIMESTAMP_RE.test(text);
|
||||
|
||||
/**
|
||||
* Resolve a track's lyrics and cache them at cache/<rel>/lyrics/<trackfile>.<lrc|txt>. Precedence:
|
||||
* external "<base>.lrc" > external "<base>.txt" > embedded (from the audio tags). Returns the cached
|
||||
* format, or null if none. `.txt`/embedded content that actually contains [mm:ss] lines is stored as .lrc.
|
||||
*/
|
||||
async function resolveTrackLyrics(
|
||||
dirAbs: string,
|
||||
lyricsDir: string,
|
||||
trackFile: string,
|
||||
folderFiles: Set<string>,
|
||||
embedded: string | undefined,
|
||||
): Promise<'lrc' | 'txt' | null> {
|
||||
const base = trackFile.replace(/\.[^.]+$/, '');
|
||||
const write = async (fmt: 'lrc' | 'txt', text: string) => {
|
||||
await mkdir(lyricsDir, { recursive: true });
|
||||
await writeFile(join(lyricsDir, `${trackFile}.${fmt}`), text);
|
||||
return fmt;
|
||||
};
|
||||
if (folderFiles.has(`${base}.lrc`)) {
|
||||
const text = await readFile(join(dirAbs, `${base}.lrc`), 'utf8').catch(() => null);
|
||||
if (text?.trim()) return write('lrc', text);
|
||||
}
|
||||
if (folderFiles.has(`${base}.txt`)) {
|
||||
const text = await readFile(join(dirAbs, `${base}.txt`), 'utf8').catch(() => null);
|
||||
if (text?.trim()) return write(isLrc(text) ? 'lrc' : 'txt', text);
|
||||
}
|
||||
if (embedded?.trim()) return write(isLrc(embedded) ? 'lrc' : 'txt', embedded);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Build ──
|
||||
|
||||
export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
@@ -385,6 +437,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
videosIndexed: 0,
|
||||
coversSaved: 0,
|
||||
postersSaved: 0,
|
||||
lyricsIndexed: 0,
|
||||
discographies: 0,
|
||||
currentPath: '',
|
||||
error: null,
|
||||
@@ -420,7 +473,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`);
|
||||
} else {
|
||||
console.log(
|
||||
`[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.videosIndexed} videos, ${r.foldersScanned} folders in ${r.elapsedSec}s`,
|
||||
`[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.videosIndexed} videos, ${status.lyricsIndexed} lyrics, ${r.foldersScanned} folders in ${r.elapsedSec}s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -484,6 +537,15 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
||||
|
||||
const hasDisco = files.some((e) => e.name === DISCO_FILE);
|
||||
|
||||
// External lyrics sidecars (.lrc/.txt) that match a track's basename — matched to tracks in the build
|
||||
// below. Only track-matching ones count (a stray notes.txt won't affect the version).
|
||||
const fileSet = new Set(files.map((e) => e.name));
|
||||
const audioBases = new Set(audio.map((n) => n.replace(/\.[^.]+$/, '')));
|
||||
const lyricsSidecars = files.map((e) => e.name).filter((n) => {
|
||||
const m = /^(.*)\.(lrc|txt)$/i.exec(n);
|
||||
return m !== null && audioBases.has(m[1]!);
|
||||
});
|
||||
|
||||
if (audio.length || video.length || coverName || hasDisco) {
|
||||
// Source signature → version (includes _discography.md so regenerating it bumps v — isolated from
|
||||
// the album meta/cover of the tracks underneath).
|
||||
@@ -496,6 +558,10 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
||||
const st = await stat(join(dirAbs, name)).catch(() => null);
|
||||
if (st) sigParts.push(`vid:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
|
||||
}
|
||||
for (const name of [...lyricsSidecars].sort()) {
|
||||
const st = await stat(join(dirAbs, name)).catch(() => null);
|
||||
if (st) sigParts.push(`lyr:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
|
||||
}
|
||||
if (coverName) {
|
||||
const st = await stat(join(dirAbs, coverName)).catch(() => null);
|
||||
if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`);
|
||||
@@ -529,8 +595,16 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
||||
}
|
||||
|
||||
if (audio.length || video.length) {
|
||||
// Regenerate lyrics cache from scratch (drop any orphaned by removed tracks/sidecars).
|
||||
const lyricsDir = join(cacheDir, 'lyrics');
|
||||
await rm(lyricsDir, { recursive: true, force: true }).catch(() => {});
|
||||
const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => {
|
||||
const t = await ffprobeTrack(join(dirAbs, name), name);
|
||||
const { track: t, lyricsText } = await ffprobeTrack(join(dirAbs, name), name);
|
||||
const fmt = await resolveTrackLyrics(dirAbs, lyricsDir, name, fileSet, lyricsText);
|
||||
if (fmt) {
|
||||
t.lyrics = fmt;
|
||||
status.lyricsIndexed += 1;
|
||||
}
|
||||
status.tracksIndexed += 1;
|
||||
emitProgress();
|
||||
return t;
|
||||
@@ -606,3 +680,7 @@ export function discographyFilePath(rel: string): string | null {
|
||||
export function posterFilePath(rel: string, file: string): string | null {
|
||||
return resolveCachePath(rel, join('posters', `${basename(file)}.jpg`));
|
||||
}
|
||||
/** A track's cached lyrics: cache/<rel>/lyrics/<file>.<lrc|txt>. `file` basename'd for path-safety. */
|
||||
export function lyricsFilePath(rel: string, file: string, fmt: 'lrc' | 'txt'): string | null {
|
||||
return resolveCachePath(rel, join('lyrics', `${basename(file)}.${fmt}`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user