use std::path::Path; use symphonia::core::{ codecs::CODEC_TYPE_NULL, formats::FormatOptions, io::MediaSourceStream, meta::{MetadataOptions, StandardTagKey}, probe::Hint, }; #[derive(Debug, Default)] pub struct RawMetadata { pub title: Option, pub artist: Option, pub album: Option, pub track_number: Option, pub year: Option, pub genre: Option, pub duration_secs: Option, } /// Extract metadata from an audio file using Symphonia. /// Must be called from a blocking context (spawn_blocking). pub fn extract(path: &Path) -> anyhow::Result { let file = std::fs::File::open(path)?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } let mut probed = symphonia::default::get_probe().format( &hint, mss, &FormatOptions { enable_gapless: false, ..Default::default() }, &MetadataOptions::default(), )?; let mut meta = RawMetadata::default(); // Check metadata side-data (e.g., ID3 tags probed before format) if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) { extract_tags(rev.tags(), &mut meta); } // Also check format-embedded metadata if let Some(rev) = probed.format.metadata().current() { if meta.title.is_none() { extract_tags(rev.tags(), &mut meta); } } // Duration meta.duration_secs = probed .format .tracks() .iter() .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) .and_then(|t| { let n_frames = t.codec_params.n_frames?; let tb = t.codec_params.time_base?; Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64) }); Ok(meta) } fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) { for tag in tags { let value = fix_encoding(tag.value.to_string()); if let Some(key) = tag.std_key { match key { StandardTagKey::TrackTitle => { if meta.title.is_none() { meta.title = Some(value); } } StandardTagKey::Artist | StandardTagKey::Performer => { if meta.artist.is_none() { meta.artist = Some(value); } } StandardTagKey::Album => { if meta.album.is_none() { meta.album = Some(value); } } StandardTagKey::TrackNumber => { if meta.track_number.is_none() { meta.track_number = value.parse().ok(); } } StandardTagKey::Date | StandardTagKey::OriginalDate => { if meta.year.is_none() { meta.year = value[..4.min(value.len())].parse().ok(); } } StandardTagKey::Genre => { if meta.genre.is_none() { meta.genre = Some(value); } } _ => {} } } } } /// Heuristic to fix mojibake (CP1251 bytes interpreted as Latin-1/Windows-1252). fn fix_encoding(s: String) -> String { let bytes: Vec = s.chars().map(|c| c as u32).filter(|&c| c <= 255).map(|c| c as u8).collect(); if bytes.len() != s.chars().count() { return s; } let has_mojibake = bytes.iter().any(|&b| b >= 0xC0); if !has_mojibake { return s; } let (decoded, _, errors) = encoding_rs::WINDOWS_1251.decode(&bytes); if errors { return s; } decoded.into_owned() }