From 77b3125af4700effbd3281825fb8258733e86106 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:03:35 -0800 Subject: [PATCH] Add `id3` extractor --- Cargo.toml | 1 + crates/pile-dataset/Cargo.toml | 1 + crates/pile-dataset/src/extract/id3.rs | 115 ++++++++++++++++++ crates/pile-dataset/src/extract/mod.rs | 10 +- .../pile-dataset/src/extract/pdf/pdf_meta.rs | 4 + .../pile-dataset/src/extract/pdf/pdf_text.rs | 4 + 6 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 crates/pile-dataset/src/extract/id3.rs diff --git a/Cargo.toml b/Cargo.toml index 34b1856..7f1ef14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ toml_edit = "0.25.4" sha2 = "0.11.0-rc.5" blake3 = "1.8.3" pdf = "0.10.0" +id3 = "1.16.4" # Misc helpers thiserror = "2.0.18" diff --git a/crates/pile-dataset/Cargo.toml b/crates/pile-dataset/Cargo.toml index c87f45d..b5d0520 100644 --- a/crates/pile-dataset/Cargo.toml +++ b/crates/pile-dataset/Cargo.toml @@ -25,3 +25,4 @@ smartstring = { workspace = true } blake3 = { workspace = true } toml_edit = { workspace = true } pdf = { workspace = true } +id3 = { workspace = true } diff --git a/crates/pile-dataset/src/extract/id3.rs b/crates/pile-dataset/src/extract/id3.rs new file mode 100644 index 0000000..951bf41 --- /dev/null +++ b/crates/pile-dataset/src/extract/id3.rs @@ -0,0 +1,115 @@ +use id3::Tag; +use pile_config::Label; +use std::{borrow::Cow, collections::HashMap, sync::OnceLock}; + +use crate::{FileItem, PileValue, extract::Extractor}; + +pub struct Id3Extractor<'a> { + item: &'a FileItem, + output: OnceLock>>, +} + +impl<'a> Id3Extractor<'a> { + pub fn new(item: &'a FileItem) -> Self { + Self { + item, + output: OnceLock::new(), + } + } + + fn get_inner(&self) -> Result<&HashMap>, std::io::Error> { + if let Some(x) = self.output.get() { + return Ok(x); + } + + let ext = self.item.path.extension().and_then(|x| x.to_str()); + if !matches!(ext, Some("mp3") | Some("aiff") | Some("aif") | Some("wav")) { + return Ok(self.output.get_or_init(HashMap::new)); + } + + let tag = match Tag::read_from_path(&self.item.path) { + Ok(tag) => tag, + Err(id3::Error { + kind: id3::ErrorKind::NoTag, + .. + }) => return Ok(self.output.get_or_init(HashMap::new)), + Err(id3::Error { + kind: id3::ErrorKind::Io(e), + .. + }) => return Err(e), + Err(e) => return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), + }; + + let mut output: HashMap>> = HashMap::new(); + for frame in tag.frames() { + if let Some(text) = frame.content().text() { + let name = frame_id_to_field(frame.id()); + if let Some(key) = Label::new(name) { + output + .entry(key) + .or_default() + .push(PileValue::String(text.into())); + } + } + } + + let output = output + .into_iter() + .map(|(k, v)| (k, PileValue::Array(v))) + .collect(); + + return Ok(self.output.get_or_init(|| output)); + } +} + +/// Map an ID3 frame ID to the equivalent Vorbis Comment field name. +/// Falls back to the lowercased frame ID if no mapping exists. +fn frame_id_to_field(id: &str) -> Cow<'static, str> { + match id { + "TIT2" => Cow::Borrowed("title"), + "TIT1" => Cow::Borrowed("grouping"), + "TIT3" => Cow::Borrowed("subtitle"), + "TPE1" => Cow::Borrowed("artist"), + "TPE2" => Cow::Borrowed("albumartist"), + "TPE3" => Cow::Borrowed("conductor"), + "TOPE" => Cow::Borrowed("originalartist"), + "TALB" => Cow::Borrowed("album"), + "TOAL" => Cow::Borrowed("originalalbum"), + "TRCK" => Cow::Borrowed("tracknumber"), + "TPOS" => Cow::Borrowed("discnumber"), + "TSST" => Cow::Borrowed("discsubtitle"), + "TDRC" | "TYER" => Cow::Borrowed("date"), + "TDOR" | "TORY" => Cow::Borrowed("originaldate"), + "TCON" => Cow::Borrowed("genre"), + "TCOM" => Cow::Borrowed("composer"), + "TEXT" => Cow::Borrowed("lyricist"), + "TPUB" => Cow::Borrowed("label"), + "TSRC" => Cow::Borrowed("isrc"), + "TBPM" => Cow::Borrowed("bpm"), + "TLAN" => Cow::Borrowed("language"), + "TMED" => Cow::Borrowed("media"), + "TMOO" => Cow::Borrowed("mood"), + "TCOP" => Cow::Borrowed("copyright"), + "TENC" => Cow::Borrowed("encodedby"), + "TSSE" => Cow::Borrowed("encodersettings"), + "TSOA" => Cow::Borrowed("albumsort"), + "TSOP" => Cow::Borrowed("artistsort"), + "TSOT" => Cow::Borrowed("titlesort"), + "MVNM" => Cow::Borrowed("movement"), + "MVIN" => Cow::Borrowed("movementnumber"), + _ => Cow::Owned(id.to_lowercase()), + } +} + +impl Extractor for Id3Extractor<'_> { + fn field<'a>( + &'a self, + name: &Label, + ) -> Result>, std::io::Error> { + Ok(self.get_inner()?.get(name)) + } + + fn fields(&self) -> Result, std::io::Error> { + Ok(self.get_inner()?.keys().cloned().collect()) + } +} diff --git a/crates/pile-dataset/src/extract/mod.rs b/crates/pile-dataset/src/extract/mod.rs index fa3ac7c..597b121 100644 --- a/crates/pile-dataset/src/extract/mod.rs +++ b/crates/pile-dataset/src/extract/mod.rs @@ -4,6 +4,9 @@ use std::{collections::HashMap, rc::Rc}; mod flac; pub use flac::*; +mod id3; +pub use id3::*; + mod fs; pub use fs::*; @@ -48,6 +51,10 @@ impl<'a> MetaExtractor<'a, crate::FileItem> { Label::new("flac").unwrap(), crate::PileValue::Extractor(Rc::new(FlacExtractor::new(item))), ), + ( + Label::new("id3").unwrap(), + crate::PileValue::Extractor(Rc::new(Id3Extractor::new(item))), + ), ( Label::new("fs").unwrap(), crate::PileValue::Extractor(Rc::new(FsExtractor::new(item))), @@ -79,9 +86,10 @@ impl Extractor for MetaExtractor<'_, crate::FileItem> { fn fields(&self) -> Result, std::io::Error> { return Ok(vec![ Label::new("flac").unwrap(), + Label::new("id3").unwrap(), Label::new("fs").unwrap(), Label::new("pdf").unwrap(), Label::new("sidecar").unwrap(), ]); } -} +} \ No newline at end of file diff --git a/crates/pile-dataset/src/extract/pdf/pdf_meta.rs b/crates/pile-dataset/src/extract/pdf/pdf_meta.rs index 6a6e4aa..db88091 100644 --- a/crates/pile-dataset/src/extract/pdf/pdf_meta.rs +++ b/crates/pile-dataset/src/extract/pdf/pdf_meta.rs @@ -23,6 +23,10 @@ impl<'a> PdfMetaExtractor<'a> { return Ok(x); } + if self.item.path.extension().map(|x| x.to_str()).flatten() != Some("pdf") { + return Ok(self.output.get_or_init(|| HashMap::new())); + } + let file = FileOptions::cached() .open(&self.item.path) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; diff --git a/crates/pile-dataset/src/extract/pdf/pdf_text.rs b/crates/pile-dataset/src/extract/pdf/pdf_text.rs index 31a0a4d..830311f 100644 --- a/crates/pile-dataset/src/extract/pdf/pdf_text.rs +++ b/crates/pile-dataset/src/extract/pdf/pdf_text.rs @@ -23,6 +23,10 @@ impl<'a> PdfTextExtractor<'a> { return Ok(x); } + if self.item.path.extension().map(|x| x.to_str()).flatten() != Some("pdf") { + return Ok(self.output.get_or_init(|| HashMap::new())); + } + let file = FileOptions::cached() .open(&self.item.path) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;