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:
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user