playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+111
View File
@@ -26,6 +26,10 @@ pub struct TagSummary {
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
/// Which disc of the release, 1-based, when the file says so. Every disc
/// numbers its tracks from 1, so without this a two-disc rip reads as two
/// track 1s, two track 2s, and so on down the album.
pub disc_no: Option<u32>,
pub year: Option<u32>,
pub duration_secs: Option<u32>,
pub bitrate_kbps: Option<u32>,
@@ -50,6 +54,7 @@ pub fn read_summary(path: &Path) -> Result<TagSummary> {
summary.album = non_empty(tag.album().as_deref());
summary.title = non_empty(tag.title().as_deref());
summary.track_no = tag.track();
summary.disc_no = tag.disk();
summary.year = tag.year();
summary.album_artist = non_empty(
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
@@ -69,6 +74,10 @@ pub struct TagWrite {
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
/// Written into the file, not just the database: a release joined from
/// disc folders loses that structure the moment it is filed flat, and the
/// tag is the only place the answer survives being read by anything else.
pub disc_no: Option<u32>,
pub year: Option<u32>,
pub label: Option<String>,
pub genre: Option<String>,
@@ -109,6 +118,7 @@ pub fn write_tags(
|| write.album.is_some()
|| write.title.is_some()
|| write.track_no.is_some()
|| write.disc_no.is_some()
|| write.year.is_some()
|| write.label.is_some()
|| write.genre.is_some();
@@ -147,6 +157,9 @@ pub fn write_tags(
if let Some(v) = write.track_no {
tag.set_track(v);
}
if let Some(v) = write.disc_no {
tag.set_disk(v);
}
if let Some(v) = write.year {
tag.set_year(v);
}
@@ -222,6 +235,104 @@ pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
Ok(())
}
/// Lyrics carried inside the audio file itself.
#[derive(Debug, Clone, Default)]
pub struct EmbeddedLyrics {
/// Unsynchronized text (ID3 USLT or the generic LYRICS field).
pub plain: Option<String>,
/// Synchronized lines from an ID3v2 SYLT frame, (milliseconds, text),
/// sorted by time. Empty when the file has none.
pub synced: Vec<(u32, String)>,
}
/// Read embedded lyrics. The plain field comes from the generic tag on every
/// format lofty knows; SYLT lives only in ID3v2, which lofty exposes as a
/// binary frame on the concrete MPEG file type, so that pass is MP3-only.
pub fn read_lyrics(path: &Path) -> Result<EmbeddedLyrics> {
use lofty::id3::v2::{Frame, SynchronizedTextFrame, TimestampFormat};
use lofty::prelude::*;
let mut out = EmbeddedLyrics::default();
let tagged = Probe::open(path)?.read()?;
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
out.plain = non_empty(
tag.get_string(&lofty::tag::ItemKey::Lyrics)
.map(|s| s.to_string())
.as_deref(),
);
}
let is_mp3 = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("mp3"));
if is_mp3 {
if let Ok(mut file) = std::fs::File::open(path) {
if let Ok(mpeg) = lofty::mpeg::MpegFile::read_from(
&mut file,
lofty::config::ParseOptions::new(),
) {
if let Some(id3) = mpeg.id3v2() {
for frame in id3 {
let Frame::Binary(binary) = frame else { continue };
if frame.id().as_str() != "SYLT" {
continue;
}
let Ok(sylt) = SynchronizedTextFrame::parse(
&binary.data,
lofty::id3::v2::FrameFlags::default(),
) else {
continue;
};
// MPEG-frame-counted timestamps cannot be turned into
// time without decoding; only millisecond SYLT is usable.
if sylt.timestamp_format != TimestampFormat::MS {
continue;
}
let mut lines: Vec<(u32, String)> = sylt
.content
.into_iter()
.map(|(ms, text)| (ms, text.trim().to_string()))
.collect();
lines.sort_by_key(|(ms, _)| *ms);
if lines.iter().any(|(_, t)| !t.is_empty()) {
out.synced = lines;
break;
}
}
}
}
}
}
Ok(out)
}
/// The file's own ISRC tag, normalized to the bare 12-character form
/// (CC-XXX-YY-NNNNN with the dashes people sometimes write stripped), or
/// None when absent or malformed. An ISRC names the exact RECORDING, which
/// makes it the one tag strong enough to match against external catalogs
/// without touching names.
pub fn read_isrc(path: &Path) -> Result<Option<String>> {
let tagged = Probe::open(path)?.read()?;
let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
return Ok(None);
};
let raw = tag
.get_string(&lofty::tag::ItemKey::Isrc)
.map(|s| s.to_string());
Ok(raw.and_then(|s| {
let normalized: String = s
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect();
let shaped = normalized.len() == 12
&& normalized.chars().take(2).all(|c| c.is_ascii_alphabetic())
&& normalized.chars().skip(5).all(|c| c.is_ascii_digit());
shaped.then_some(normalized)
}))
}
fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
}