Added AI agent to manage metadata
This commit is contained in:
129
furumi-agent/src/ingest/metadata.rs
Normal file
129
furumi-agent/src/ingest/metadata.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub track_number: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub duration_secs: Option<f64>,
|
||||
}
|
||||
|
||||
/// Extract metadata from an audio file using Symphonia.
|
||||
/// Must be called from a blocking context (spawn_blocking).
|
||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
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<u8> = 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()
|
||||
}
|
||||
Reference in New Issue
Block a user