use id3::Tag; use pile_config::Label; use std::{ borrow::Cow, collections::HashMap, io::BufReader, sync::{Arc, OnceLock}, }; use tracing::trace; use crate::{ extract::traits::{ExtractState, ObjectExtractor}, value::{Item, PileValue, SyncReadBridge}, }; pub struct Id3Extractor { item: Item, output: OnceLock>, } impl Id3Extractor { pub fn new(item: &Item) -> Self { Self { item: item.clone(), output: OnceLock::new(), } } async fn get_inner(&self) -> Result<&HashMap, std::io::Error> { if let Some(x) = self.output.get() { return Ok(x); } trace!(message = "Reading id3 tags", key = self.item.key().as_str()); let key = self.item.key(); let reader = SyncReadBridge::new_current(self.item.read().await?); let tag = match tokio::task::spawn_blocking(move || Tag::read_from2(BufReader::new(reader))) .await { Ok(Ok(tag)) => tag, Err(e) => return Err(e.into()), Ok(Err(id3::Error { kind: id3::ErrorKind::Io(e), .. })) => return Err(e), Ok(Err(error)) => { trace!( message = "Could not parse id3 tags", key = key.as_str(), ?error ); return Ok(self.output.get_or_init(HashMap::new)); } }; let mut output: HashMap> = HashMap::new(); for frame in tag.frames() { if let Some(texts) = frame.content().text_values() { let name = frame_id_to_field(frame.id()); if let Some(key) = Label::new(name) { for text in texts { output .entry(key.clone()) .or_default() .push(PileValue::String(Arc::new(text.into()))); } } } } let output = output .into_iter() .map(|(k, v)| (k, PileValue::Array(Arc::new(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 { // spell:off "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()), // spell:on } } #[async_trait::async_trait] impl ObjectExtractor for Id3Extractor { async fn field( &self, state: &ExtractState, name: &Label, args: Option<&str>, ) -> Result, std::io::Error> { if args.is_some() { return Ok(None); } if !state.ignore_mime && self.item.mime().essence_str() != "audio/mpeg" { return Ok(None); } Ok(self.get_inner().await?.get(name).cloned()) } async fn fields(&self) -> Result, std::io::Error> { Ok(self.get_inner().await?.keys().cloned().collect()) } }