fix(lyrics): read SYNCEDLYRICS from the concrete Vorbis comment block (#1267)

* 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)
This commit is contained in:
Psychotoxical
2026-07-10 04:07:03 +02:00
committed by GitHub
parent ed99c8f2f7
commit 51140c613a
2 changed files with 202 additions and 11 deletions
+6
View File
@@ -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
+196 -11
View File
@@ -175,6 +175,47 @@ pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Optio
Ok(if lrc.is_empty() { None } else { Some(lrc) })
}
/// Reads the `SYNCEDLYRICS` Vorbis comment, which holds a complete LRC string.
///
/// Vorbis comments allow arbitrary keys, but lofty's generic `Tag` only carries
/// the keys it knows and silently drops the rest — `SYNCEDLYRICS` among them, so
/// `ItemKey::from_key` can never resolve it. Read the concrete comment block.
fn vorbis_synced_lyrics(path: &std::path::Path, file_type: lofty::file::FileType) -> Option<String> {
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<String> {
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 `<mm:ss.xx>` 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());
}
}