use axum::{ extract::{Query, State}, http::StatusCode, response::{IntoResponse, Json}, }; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use crate::security::sanitize_path; use super::WebState; #[derive(Deserialize)] pub struct BrowseQuery { #[serde(default)] pub path: String, } #[derive(Serialize)] pub struct BrowseResponse { pub path: String, pub entries: Vec, } #[derive(Serialize)] pub struct Entry { pub name: String, #[serde(rename = "type")] pub kind: EntryKind, #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, } #[derive(Serialize)] #[serde(rename_all = "lowercase")] pub enum EntryKind { File, Dir, } pub async fn handler( State(state): State, Query(query): Query, ) -> impl IntoResponse { let safe = match sanitize_path(&query.path) { Ok(p) => p, Err(_) => { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid path"}))).into_response(); } }; let dir_path: PathBuf = state.root.join(&safe); let read_dir = match tokio::fs::read_dir(&dir_path).await { Ok(rd) => rd, Err(e) => { let status = if e.kind() == std::io::ErrorKind::NotFound { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; return (status, Json(serde_json::json!({"error": e.to_string()}))).into_response(); } }; let mut entries: Vec = Vec::new(); let mut rd = read_dir; loop { match rd.next_entry().await { Ok(Some(entry)) => { let name = entry.file_name().to_string_lossy().into_owned(); // Skip hidden files if name.starts_with('.') { continue; } let meta = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; if meta.is_dir() { entries.push(Entry { name, kind: EntryKind::Dir, size: None }); } else if meta.is_file() { // Only expose audio files if is_audio_file(&name) { entries.push(Entry { name, kind: EntryKind::File, size: Some(meta.len()), }); } } } Ok(None) => break, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) .into_response(); } } } // Sort: dirs first, then files; alphabetically within each group entries.sort_by(|a, b| { let a_dir = matches!(a.kind, EntryKind::Dir); let b_dir = matches!(b.kind, EntryKind::Dir); b_dir.cmp(&a_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); let response = BrowseResponse { path: safe, entries, }; (StatusCode::OK, Json(response)).into_response() } /// Whitelist of audio extensions served via the web player. pub fn is_audio_file(name: &str) -> bool { let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); matches!( ext.as_str(), "mp3" | "flac" | "ogg" | "opus" | "aac" | "m4a" | "wav" | "ape" | "wv" | "wma" | "tta" | "aiff" | "aif" ) } /// Returns true if the format needs transcoding (not natively supported by browsers). pub fn needs_transcode(name: &str) -> bool { let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); matches!(ext.as_str(), "ape" | "wv" | "wma" | "tta" | "aiff" | "aif") }