Files
furumi-ng/furumi-server/src/web/meta.rs
2026-03-17 13:49:03 +00:00

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));
}
}
}