205 lines
6.5 KiB
Rust
205 lines
6.5 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 {
|
|
let value = fix_encoding(tag.value.to_string());
|
|
if let Some(key) = tag.std_key {
|
|
match key {
|
|
StandardTagKey::TrackTitle => {
|
|
*title = Some(value);
|
|
}
|
|
StandardTagKey::Artist | StandardTagKey::Performer => {
|
|
if artist.is_none() {
|
|
*artist = Some(value);
|
|
}
|
|
}
|
|
StandardTagKey::Album => {
|
|
*album = Some(value);
|
|
}
|
|
StandardTagKey::TrackNumber => {
|
|
if track.is_none() {
|
|
*track = value.parse().ok();
|
|
}
|
|
}
|
|
StandardTagKey::Date | StandardTagKey::OriginalDate => {
|
|
if year.is_none() {
|
|
// Parse first 4 characters as year
|
|
*year = value[..4.min(value.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));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Heuristic to fix mojibake (CP1251 bytes interpreted as Latin-1/Windows-1252)
|
|
fn fix_encoding(s: String) -> String {
|
|
// If it's already a valid UTF-8 string that doesn't look like mojibake, return it.
|
|
// Mojibake looks like characters from Latin-1 Supplement (0xC0-0xFF)
|
|
// where they should be Cyrillic.
|
|
|
|
let bytes: Vec<u8> = s.chars().map(|c| c as u32).filter(|&c| c <= 255).map(|c| c as u8).collect();
|
|
|
|
// If the length is different, it means there were characters > 255, so it's not simple Latin-1 mojibake.
|
|
if bytes.len() != s.chars().count() {
|
|
return s;
|
|
}
|
|
|
|
// Check if it's likely CP1251. Russian characters in CP1251 are 0xC0-0xFF.
|
|
// In Latin-1 these are characters like À-ÿ.
|
|
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()
|
|
}
|