mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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.
|
* 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
|
## [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) })
|
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.
|
/// Reads embedded synced / unsynced lyrics from a local audio file.
|
||||||
///
|
///
|
||||||
/// Priority order:
|
/// Priority order:
|
||||||
@@ -258,19 +299,17 @@ pub fn get_embedded_lyrics(path: String) -> Option<String> {
|
|||||||
return None; // MPEG file but no usable lyrics found
|
return None; // MPEG file but no usable lyrics found
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ────────────────────
|
// ── FLAC / Vorbis / Opus / M4A ───────────────────────────────────────────
|
||||||
// Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field.
|
// SYNCEDLYRICS is not a key lofty knows, so it only exists on the concrete
|
||||||
// In newer lofty versions, construct dynamic keys via ItemKey::from_key.
|
// 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()?;
|
let tagged = probe.read().ok()?;
|
||||||
for tag in tagged.tags() {
|
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) {
|
if let Some(plain) = tag.get_string(ItemKey::Lyrics) {
|
||||||
let plain = plain.trim();
|
let plain = plain.trim();
|
||||||
if !plain.is_empty() {
|
if !plain.is_empty() {
|
||||||
@@ -421,3 +460,149 @@ pub struct HotCacheDownloadResult {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub size: u64,
|
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