use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Json}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use serde::Serialize; use symphonia::core::{ codecs::CODEC_TYPE_NULL, formats::FormatOptions, io::MediaSourceStream, meta::{MetadataOptions, StandardTagKey}, probe::Hint, }; use crate::security::sanitize_path; use super::WebState; #[derive(Serialize)] pub struct MetaResponse { pub title: Option, pub artist: Option, pub album: Option, pub track: Option, pub year: Option, pub duration_secs: Option, pub cover_base64: Option, // "data:image/jpeg;base64,..." } pub async fn handler( State(state): State, Path(path): Path, ) -> impl IntoResponse { let safe = match sanitize_path(&path) { Ok(p) => p, Err(_) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid path"}))).into_response(), }; let file_path = state.root.join(&safe); let filename = file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_owned(); let meta = tokio::task::spawn_blocking(move || read_meta(file_path, &filename)).await; match meta { Ok(Ok(m)) => (StatusCode::OK, Json(m)).into_response(), Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response(), } } fn read_meta(file_path: std::path::PathBuf, filename: &str) -> anyhow::Result { let file = std::fs::File::open(&file_path)?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = Hint::new(); if let Some(ext) = file_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(), )?; // Extract tags from container-level metadata let mut title: Option = None; let mut artist: Option = None; let mut album: Option = None; let mut track: Option = None; let mut year: Option = None; let mut cover_data: Option<(Vec, String)> = None; // 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(), rev.visuals(), &mut title, &mut artist, &mut album, &mut track, &mut year, &mut cover_data); } // Also check format-embedded metadata if let Some(rev) = probed.format.metadata().current() { if title.is_none() { extract_tags(rev.tags(), rev.visuals(), &mut title, &mut artist, &mut album, &mut track, &mut year, &mut cover_data); } } // If no title from tags, use filename without extension if title.is_none() { title = Some( std::path::Path::new(filename) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(filename) .to_owned(), ); } // Estimate duration from track time_base + n_frames let 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) }); let cover_base64 = cover_data.map(|(data, mime)| { format!("data:{};base64,{}", mime, BASE64.encode(&data)) }); Ok(MetaResponse { title, artist, album, track, year, duration_secs, cover_base64, }) } fn extract_tags( tags: &[symphonia::core::meta::Tag], visuals: &[symphonia::core::meta::Visual], title: &mut Option, artist: &mut Option, album: &mut Option, track: &mut Option, year: &mut Option, cover: &mut Option<(Vec, String)>, ) { for tag in tags { if let Some(key) = tag.std_key { match key { StandardTagKey::TrackTitle => { *title = Some(tag.value.to_string()); } StandardTagKey::Artist | StandardTagKey::Performer => { if artist.is_none() { *artist = Some(tag.value.to_string()); } } StandardTagKey::Album => { *album = Some(tag.value.to_string()); } StandardTagKey::TrackNumber => { if track.is_none() { *track = tag.value.to_string().parse().ok(); } } StandardTagKey::Date | StandardTagKey::OriginalDate => { if year.is_none() { // Parse first 4 characters as year *year = tag.value.to_string()[..4.min(tag.value.to_string().len())].parse().ok(); } } _ => {} } } } if cover.is_none() { if let Some(visual) = visuals.first() { let mime = visual.media_type.clone(); *cover = Some((visual.data.to_vec(), mime)); } } }