Added web player
This commit is contained in:
175
furumi-server/src/web/meta.rs
Normal file
175
furumi-server/src/web/meta.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user