Added web player
This commit is contained in:
132
furumi-server/src/web/browse.rs
Normal file
132
furumi-server/src/web/browse.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
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<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: EntryKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Dir,
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<WebState>,
|
||||
Query(query): Query<BrowseQuery>,
|
||||
) -> 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<Entry> = 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")
|
||||
}
|
||||
Reference in New Issue
Block a user