mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user