playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user