From c99b9c6d77154cdd29816c4befe2213b43a65855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 17:34:58 +0000 Subject: [PATCH] music: embedded lyrics are canonical (quality-aware precedence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/sidecar/music/indexer.ts | 44 +++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index 7bd49ddf..3edf727b 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -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//lyrics/.. Precedence: - * external ".lrc" > external ".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//lyrics/.. 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 ──