176 lines
5.6 KiB
Rust
176 lines
5.6 KiB
Rust
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<String>,
|
|
pub artist: Option<String>,
|
|
pub album: Option<String>,
|
|
pub track: Option<u32>,
|
|
pub year: Option<u32>,
|
|
pub duration_secs: Option<f64>,
|
|
pub cover_base64: Option<String>, // "data:image/jpeg;base64,..."
|
|
}
|
|
|
|
pub async fn handler(
|
|
State(state): State<WebState>,
|
|
Path(path): Path<String>,
|
|
) -> 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<MetaResponse> {
|
|
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<String> = None;
|
|
let mut artist: Option<String> = None;
|
|
let mut album: Option<String> = None;
|
|
let mut track: Option<u32> = None;
|
|
let mut year: Option<u32> = None;
|
|
let mut cover_data: Option<(Vec<u8>, 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<String>,
|
|
artist: &mut Option<String>,
|
|
album: &mut Option<String>,
|
|
track: &mut Option<u32>,
|
|
year: &mut Option<u32>,
|
|
cover: &mut Option<(Vec<u8>, 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));
|
|
}
|
|
}
|
|
}
|