music: embedded lyrics are canonical (quality-aware precedence)

Rework resolveTrackLyrics so a track's OWN embedded lyrics win, and SYNCED always
beats plain:
  synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt)

So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag)
becomes the canonical source over leftover .lrc/.txt sidecars — but a track with
only a *plain* embed still serves a synced .lrc until it's re-embedded (no silent
downgrade). Binary SYLT frames aren't readable here, so sync must be stored as
LRC text in the text lyrics tag (verified it round-trips + is detected as lrc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:34:58 +00:00
co-authored by Claude Opus 4.8
parent 11263ce120
commit c99b9c6d77
+27 -17
View File
@@ -450,9 +450,15 @@ 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.
* Resolve a track's lyrics and cache them at cache/<rel>/lyrics/<trackfile>.<lrc|txt>. Returns the
* cached format, or null if none. Content (embedded or a .txt) that actually contains [mm:ss] lines is
* treated as synced (`lrc`).
*
* Precedence — the file's OWN embedded lyrics are canonical, and SYNCED always beats plain:
* synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt)
* So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag) wins over leftover
* sidecars, but a track that only has a *plain* embed still serves a synced `.lrc` until it's re-embedded
* — no silent downgrade. (Binary SYLT frames aren't readable here; sync must live in the text tag.)
*/
async function resolveTrackLyrics(
dirAbs: string,
@@ -462,21 +468,25 @@ async function resolveTrackLyrics(
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);
type Candidate = { text: string; fmt: 'lrc' | 'txt'; embedded: boolean };
const candidates: Candidate[] = [];
if (embedded?.trim()) candidates.push({ text: embedded, fmt: isLrc(embedded) ? 'lrc' : 'txt', embedded: true });
for (const name of [`${base}.lrc`, `${base}.txt`]) {
if (!folderFiles.has(name)) continue;
const text = await readFile(join(dirAbs, name), 'utf8').catch(() => null);
if (text?.trim()) candidates.push({ text, fmt: isLrc(text) ? 'lrc' : 'txt', embedded: false });
}
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;
if (!candidates.length) return null;
// Rank: synced (0) before plain (2); within a tier, the file's own embedded copy (0) before a sidecar (1).
const rank = (c: Candidate) => (c.fmt === 'lrc' ? 0 : 2) + (c.embedded ? 0 : 1);
candidates.sort((a, b) => rank(a) - rank(b));
const best = candidates[0]!;
await mkdir(lyricsDir, { recursive: true });
await writeFile(join(lyricsDir, `${trackFile}.${best.fmt}`), best.text);
return best.fmt;
}
// ── Build ──