From 51140c613a4f7a4ac073c53cfde81c334a6c6f3a Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:07:03 +0200 Subject: [PATCH] fix(lyrics): read SYNCEDLYRICS from the concrete Vorbis comment block (#1267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(lyrics): read SYNCEDLYRICS from the concrete Vorbis comment block Embedded lyrics were never found on a FLAC/Ogg file whose only lyrics field is `SYNCEDLYRICS`. The lookup went through lofty's generic tag via `ItemKey::from_key(tag_type, "SYNCEDLYRICS")`, which returns `None` because the key is not one lofty knows — and the generic tag drops unknown Vorbis comments entirely on conversion, so the value was not reachable there at all. The branch had been dead since it was written; files fell through to the `LYRICS` field or reported no lyrics. Read the comment block off the concrete file type (FLAC, Vorbis, Opus, Speex) instead, keeping the documented priority: SYNCEDLYRICS before plain LYRICS. Tests cover the reader end to end against generated files: SYNCEDLYRICS and LYRICS returned verbatim (Enhanced LRC word stamps included), SYNCEDLYRICS winning over a plain fallback, USLT returned verbatim, and SYLT rebuilt as line-level LRC — which is why that source can never carry word timing. * docs(changelog): Vorbis SYNCEDLYRICS fix (#1267) --- CHANGELOG.md | 6 + .../psysonic-syncfs/src/cache/downloads.rs | 207 +++++++++++++++++- 2 files changed, 202 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24bf882..10a6b90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -229,6 +229,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Tracks whose embedded lyrics use Enhanced LRC displayed the raw word timing codes (`<00:12.34>`) inside each line. The codes are now read as word timing instead of printed, so those lyrics also highlight word by word. +### Lyrics — synced lyrics in FLAC and Ogg files + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1267](https://github.com/Psychotoxical/psysonic/pull/1267)** + +* FLAC, Ogg Vorbis, Opus and Speex files that store their synced lyrics in the `SYNCEDLYRICS` tag showed no embedded lyrics at all. That tag is now read, and it takes priority over the plain `LYRICS` tag as intended. + ## [1.49.0] - 2026-06-29 diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs index 7ae20781..b51c4a2a 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs @@ -175,6 +175,47 @@ pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result Option { + use lofty::config::ParseOptions; + use lofty::file::{AudioFile, FileType}; + + const SYNCED_LYRICS: &str = "SYNCEDLYRICS"; + let mut file = std::fs::File::open(path).ok()?; + let options = ParseOptions::new(); + + let lrc = match file_type { + FileType::Flac => lofty::flac::FlacFile::read_from(&mut file, options) + .ok()? + .vorbis_comments()? + .get(SYNCED_LYRICS)? + .to_owned(), + FileType::Vorbis => lofty::ogg::VorbisFile::read_from(&mut file, options) + .ok()? + .vorbis_comments() + .get(SYNCED_LYRICS)? + .to_owned(), + FileType::Opus => lofty::ogg::OpusFile::read_from(&mut file, options) + .ok()? + .vorbis_comments() + .get(SYNCED_LYRICS)? + .to_owned(), + FileType::Speex => lofty::ogg::SpeexFile::read_from(&mut file, options) + .ok()? + .vorbis_comments() + .get(SYNCED_LYRICS)? + .to_owned(), + _ => return None, + }; + + let trimmed = lrc.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + /// Reads embedded synced / unsynced lyrics from a local audio file. /// /// Priority order: @@ -258,19 +299,17 @@ pub fn get_embedded_lyrics(path: String) -> Option { return None; // MPEG file but no usable lyrics found } - // ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ──────────────────── - // Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field. - // In newer lofty versions, construct dynamic keys via ItemKey::from_key. + // ── FLAC / Vorbis / Opus / M4A ─────────────────────────────────────────── + // SYNCEDLYRICS is not a key lofty knows, so it only exists on the concrete + // Vorbis comment block — the generic tag below would never surface it. + if let Some(file_type) = file_type { + if let Some(lrc) = vorbis_synced_lyrics(fpath, file_type) { + return Some(lrc); + } + } + let tagged = probe.read().ok()?; for tag in tagged.tags() { - if let Some(sync_key) = ItemKey::from_key(tag.tag_type(), "SYNCEDLYRICS") { - if let Some(lrc) = tag.get_string(sync_key) { - let lrc = lrc.trim(); - if !lrc.is_empty() { - return Some(lrc.to_owned()); - } - } - } if let Some(plain) = tag.get_string(ItemKey::Lyrics) { let plain = plain.trim(); if !plain.is_empty() { @@ -421,3 +460,149 @@ pub struct HotCacheDownloadResult { pub path: String, pub size: u64, } + +#[cfg(test)] +mod tests { + use super::get_embedded_lyrics; + use std::path::{Path, PathBuf}; + + /// An Enhanced LRC body: a line stamp plus inline `` word stamps. + const ENHANCED_LRC: &str = "[00:12.00]<00:12.00>Hello <00:12.90>world"; + + /// Minimal MPEG-1 Layer III frame header plus padding — enough for lofty to + /// identify the file as MPEG. The audio itself is never decoded here. + fn write_fake_mp3(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + let mut bytes = vec![0xFF, 0xFB, 0x90, 0x64]; + bytes.extend(std::iter::repeat_n(0u8, 512)); + std::fs::write(&path, bytes).unwrap(); + path + } + + /// A FLAC stream with nothing but a STREAMINFO block: 44.1 kHz, stereo, + /// 16-bit, no audio frames. + fn write_minimal_flac(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + let mut bytes = b"fLaC".to_vec(); + bytes.push(0x80); // last-metadata-block flag + block type 0 (STREAMINFO) + bytes.extend_from_slice(&[0x00, 0x00, 0x22]); // block length = 34 + let mut streaminfo = [0u8; 34]; + streaminfo[0..2].copy_from_slice(&4096u16.to_be_bytes()); // min block size + streaminfo[2..4].copy_from_slice(&4096u16.to_be_bytes()); // max block size + // sample rate (20 bits) | channels-1 (3) | bits-per-sample-1 (5) | total samples (36) + streaminfo[10] = 0x0A; + streaminfo[11] = 0xC4; + streaminfo[12] = 0x42; + streaminfo[13] = 0xF0; + bytes.extend_from_slice(&streaminfo); + std::fs::write(&path, bytes).unwrap(); + path + } + + fn write_flac_comment(dir: &Path, name: &str, key: &str, value: &str) -> PathBuf { + use lofty::config::WriteOptions; + use lofty::ogg::VorbisComments; + use lofty::prelude::TagExt; + + let path = write_minimal_flac(dir, name); + let mut comments = VorbisComments::default(); + comments.push(key.to_owned(), value.to_owned()); + comments.save_to_path(&path, WriteOptions::default()).unwrap(); + path + } + + #[test] + fn uslt_enhanced_lrc_is_returned_verbatim() { + use id3::{frame::Lyrics, Content, Frame, Tag, TagLike, Version}; + + let dir = tempfile::tempdir().unwrap(); + let path = write_fake_mp3(dir.path(), "uslt.mp3"); + + let mut tag = Tag::new(); + tag.add_frame(Frame::with_content( + "USLT", + Content::Lyrics(Lyrics { + lang: "eng".into(), + description: String::new(), + text: ENHANCED_LRC.into(), + }), + )); + tag.write_to_path(&path, Version::Id3v24).unwrap(); + + // The inline word stamps must survive the read untouched — the frontend + // parser is what turns them into word timing. + let got = get_embedded_lyrics(path.to_string_lossy().into_owned()); + assert_eq!(got.as_deref(), Some(ENHANCED_LRC)); + } + + #[test] + fn sylt_is_rebuilt_as_line_level_lrc_and_can_carry_no_word_stamps() { + use id3::{ + frame::{SynchronisedLyrics, SynchronisedLyricsType, TimestampFormat}, + Content, Frame, Tag, TagLike, Version, + }; + + let dir = tempfile::tempdir().unwrap(); + let path = write_fake_mp3(dir.path(), "sylt.mp3"); + + let mut tag = Tag::new(); + tag.add_frame(Frame::with_content( + "SYLT", + Content::SynchronisedLyrics(SynchronisedLyrics { + lang: "eng".into(), + timestamp_format: TimestampFormat::Ms, + content_type: SynchronisedLyricsType::Lyrics, + description: String::new(), + content: vec![(12_000, "Hello world".into()), (74_500, "bye".into())], + }), + )); + tag.write_to_path(&path, Version::Id3v24).unwrap(); + + let got = get_embedded_lyrics(path.to_string_lossy().into_owned()).unwrap(); + assert_eq!(got, "[00:12.00]Hello world\n[01:14.50]bye"); + // SYLT timestamps are per line, so this source can never carry word timing. + assert!(!got.contains('<')); + } + + #[test] + fn vorbis_synced_lyrics_is_returned_verbatim() { + // Regression: `SYNCEDLYRICS` is not a key lofty knows, so reading it off + // the generic tag always failed and the file looked lyrics-free. + let dir = tempfile::tempdir().unwrap(); + let path = write_flac_comment(dir.path(), "synced.flac", "SYNCEDLYRICS", ENHANCED_LRC); + + let got = get_embedded_lyrics(path.to_string_lossy().into_owned()); + assert_eq!(got.as_deref(), Some(ENHANCED_LRC)); + } + + #[test] + fn vorbis_lyrics_field_is_returned_verbatim() { + let dir = tempfile::tempdir().unwrap(); + let path = write_flac_comment(dir.path(), "lyrics.flac", "LYRICS", ENHANCED_LRC); + + let got = get_embedded_lyrics(path.to_string_lossy().into_owned()); + assert_eq!(got.as_deref(), Some(ENHANCED_LRC)); + } + + #[test] + fn vorbis_synced_lyrics_wins_over_plain_lyrics() { + use lofty::config::WriteOptions; + use lofty::ogg::VorbisComments; + use lofty::prelude::TagExt; + + let dir = tempfile::tempdir().unwrap(); + let path = write_minimal_flac(dir.path(), "both.flac"); + let mut comments = VorbisComments::default(); + comments.push("SYNCEDLYRICS".to_owned(), ENHANCED_LRC.to_owned()); + comments.push("LYRICS".to_owned(), "plain fallback".to_owned()); + comments.save_to_path(&path, WriteOptions::default()).unwrap(); + + let got = get_embedded_lyrics(path.to_string_lossy().into_owned()); + assert_eq!(got.as_deref(), Some(ENHANCED_LRC)); + } + + #[test] + fn missing_file_yields_none() { + assert!(get_embedded_lyrics("does/not/exist.mp3".into()).is_none()); + } +}