use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde_json::json;
use super::super::safe_path::{SafePath, VerifiedRoot};
use super::super::server::WebUiState;
use crate::songs;
fn project_root(state: &WebUiState) -> Result<VerifiedRoot, Box<axum::response::Response>> {
let config_canonical = state.config_path.canonicalize().map_err(|e| {
Box::new(
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to resolve config path: {}", e)})),
)
.into_response(),
)
})?;
let parent = config_canonical.parent().ok_or_else(|| {
Box::new(
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Unable to determine config root directory"})),
)
.into_response(),
)
})?;
VerifiedRoot::new(parent).map_err(|e| Box::new(e.into_response()))
}
fn resolve_browse_path(
root: &VerifiedRoot,
path: &str,
) -> Result<SafePath, Box<axum::response::Response>> {
if path.is_empty() || path == "/" {
return Ok(root.as_safe_path());
}
let relative = path.strip_prefix('/').unwrap_or(path);
SafePath::resolve(&root.as_path().join(relative), root).map_err(|_| {
Box::new(
(
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Not a directory: {}", path)})),
)
.into_response(),
)
})
}
pub(super) async fn browse_directory(
State(state): State<WebUiState>,
Query(params): Query<BrowseParams>,
) -> impl IntoResponse {
let root = match project_root(&state) {
Ok(r) => r,
Err(e) => return *e,
};
let dir_safe = match resolve_browse_path(&root, ¶ms.path) {
Ok(p) => p,
Err(e) => return *e,
};
if !dir_safe.is_dir() {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Not a directory: {}", params.path)})),
)
.into_response();
}
let root_canonical = root.as_path().to_path_buf();
let dir_canonical = dir_safe.as_path().to_path_buf();
let to_relative = |abs: &std::path::Path| -> String {
let suffix = abs
.strip_prefix(&root_canonical)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if suffix.is_empty() {
"/".to_string()
} else {
format!("/{suffix}")
}
};
let mut entries: Vec<serde_json::Value> = Vec::new();
match std::fs::read_dir(&dir_canonical) {
Ok(iter) => {
for entry in iter.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with('.') {
continue;
}
let is_dir = path.is_dir();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let file_type = if is_dir {
"directory"
} else if songs::is_supported_audio_extension(&ext) {
"audio"
} else if ext == "mid" {
"midi"
} else if ext == "light" {
"lighting"
} else {
"other"
};
entries.push(json!({
"name": name,
"path": to_relative(&path),
"type": file_type,
"is_dir": is_dir,
}));
}
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to read directory: {}", e)})),
)
.into_response();
}
}
entries.sort_by(|a, b| {
let a_dir = a.get("is_dir").and_then(|v| v.as_bool()).unwrap_or(false);
let b_dir = b.get("is_dir").and_then(|v| v.as_bool()).unwrap_or(false);
b_dir.cmp(&a_dir).then_with(|| {
a.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase()
.cmp(
&b.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase(),
)
})
});
(
StatusCode::OK,
Json(json!({
"path": to_relative(&dir_canonical),
"root": root_canonical.to_string_lossy(),
"entries": entries,
})),
)
.into_response()
}
#[derive(serde::Deserialize)]
pub(super) struct BrowseParams {
#[serde(default)]
path: String,
}
pub(super) async fn create_song_in_directory(
State(state): State<WebUiState>,
Json(body): Json<CreateSongInDirRequest>,
) -> impl IntoResponse {
let root = match project_root(&state) {
Ok(r) => r,
Err(e) => return *e,
};
let dir_safe = match resolve_browse_path(&root, &body.path) {
Ok(p) => p,
Err(e) => return *e,
};
if !dir_safe.is_dir() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Path is not a directory"})),
)
.into_response();
}
let dir_canonical = dir_safe.as_path().to_path_buf();
let config_path = dir_canonical.join("song.yaml");
if config_path.exists() {
return (
StatusCode::CONFLICT,
Json(json!({"error": "song.yaml already exists in this directory"})),
)
.into_response();
}
let mut song_config = match songs::Song::initialize(&dir_canonical) {
Ok(song) => song.get_config(),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("Failed to scan directory: {}", e)})),
)
.into_response();
}
};
if let Some(ref name) = body.name {
let trimmed = name.trim();
if !trimmed.is_empty() {
song_config.set_name(trimmed);
}
}
match song_config.save(&config_path) {
Ok(()) => {
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
(
StatusCode::CREATED,
Json(json!({"status": "created", "path": config_path.to_string_lossy()})),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to save song config: {}", e)})),
)
.into_response(),
}
}
#[derive(serde::Deserialize)]
pub(super) struct CreateSongInDirRequest {
path: String,
name: Option<String>,
}
pub(super) async fn bulk_import(
State(state): State<WebUiState>,
Json(body): Json<BulkImportRequest>,
) -> impl IntoResponse {
let root = match project_root(&state) {
Ok(r) => r,
Err(e) => return *e,
};
let dir_safe = match resolve_browse_path(&root, &body.path) {
Ok(p) => p,
Err(e) => return *e,
};
if !dir_safe.is_dir() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Path is not a directory"})),
)
.into_response();
}
let dir_canonical = dir_safe.as_path().to_path_buf();
let mut created: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut failed: Vec<serde_json::Value> = Vec::new();
bulk_import_recursive(
&dir_canonical,
&dir_canonical,
&mut created,
&mut skipped,
&mut failed,
);
if !created.is_empty() {
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
}
(
StatusCode::OK,
Json(json!({
"created": created,
"skipped": skipped,
"failed": failed,
})),
)
.into_response()
}
fn bulk_import_recursive(
dir: &std::path::Path,
root: &std::path::Path,
created: &mut Vec<String>,
skipped: &mut Vec<String>,
failed: &mut Vec<serde_json::Value>,
) {
let read_dir = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(_) => return,
};
let mut subdirs: Vec<std::path::PathBuf> = Vec::new();
let mut has_audio = false;
for entry in read_dir.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.starts_with('.') {
subdirs.push(path);
}
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if songs::is_supported_audio_extension(&ext.to_lowercase()) {
has_audio = true;
}
}
}
subdirs.sort();
let display_name = dir
.strip_prefix(root)
.unwrap_or(dir)
.to_string_lossy()
.to_string();
let display_name = if display_name.is_empty() {
dir.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
} else {
display_name
};
if has_audio || dir != root {
if dir == root {
} else if dir.join("song.yaml").exists() {
skipped.push(display_name);
return;
} else if has_audio {
let dir_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
match songs::Song::initialize(&dir.to_path_buf()) {
Ok(song) => {
let mut config = song.get_config();
config.set_name(&dir_name);
match config.save(&dir.join("song.yaml")) {
Ok(()) => created.push(display_name),
Err(e) => failed.push(json!({
"name": display_name,
"error": format!("Failed to save: {}", e),
})),
}
}
Err(e) => {
failed.push(json!({
"name": display_name,
"error": format!("{}", e),
}));
}
}
return; }
}
for subdir in &subdirs {
bulk_import_recursive(subdir, root, created, skipped, failed);
}
}
#[derive(serde::Deserialize)]
pub(super) struct BulkImportRequest {
path: String,
}
#[cfg(test)]
mod test {
use super::super::router;
use super::super::test_helpers::*;
use axum::body::Body;
use axum::http::StatusCode;
use tower::ServiceExt;
#[tokio::test]
async fn browse_directory_default_lists_project_root() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/browse")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed.get("path").is_some());
assert!(parsed.get("root").is_some());
assert!(parsed.get("entries").is_some());
assert!(parsed["entries"].is_array());
}
#[tokio::test]
async fn browse_directory_with_path() {
let (state, _dir) = test_state();
let subdir = _dir.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("file.txt"), "hello").unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/browse?path=/subdir")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let entries = parsed["entries"].as_array().unwrap();
assert!(!entries.is_empty());
}
#[tokio::test]
async fn browse_directory_path_traversal_rejected() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/browse?path=/../../../etc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
assert!(
status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND,
"expected 403 or 404, got {status}"
);
}
#[tokio::test]
async fn create_song_in_directory_success() {
let (state, _dir) = test_state();
let song_dir = _dir.path().join("songs").join("mysong");
std::fs::create_dir_all(&song_dir).unwrap();
let wav_bytes = create_test_wav();
std::fs::write(song_dir.join("track.wav"), &wav_bytes).unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/create-song")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/songs/mysong"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["status"], "created");
}
#[tokio::test]
async fn create_song_in_directory_conflict() {
let (state, _dir) = test_state();
let song_dir = _dir.path().join("songs").join("existing");
std::fs::create_dir_all(&song_dir).unwrap();
std::fs::write(song_dir.join("song.yaml"), "name: existing\ntracks: []\n").unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/create-song")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/songs/existing"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn browse_directory_classifies_file_types() {
let (state, _dir) = test_state();
let subdir = _dir.path().join("typedir");
std::fs::create_dir(&subdir).unwrap();
let wav_bytes = create_test_wav();
std::fs::write(subdir.join("track.wav"), &wav_bytes).unwrap();
std::fs::write(subdir.join("notes.mid"), "midi data").unwrap();
std::fs::write(subdir.join("show.light"), "light data").unwrap();
std::fs::write(subdir.join("readme.txt"), "text data").unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/browse?path=/typedir")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let entries = parsed["entries"].as_array().unwrap();
let find_entry = |name: &str| entries.iter().find(|e| e["name"] == name).unwrap();
assert_eq!(find_entry("track.wav")["type"], "audio");
assert_eq!(find_entry("notes.mid")["type"], "midi");
assert_eq!(find_entry("show.light")["type"], "lighting");
assert_eq!(find_entry("readme.txt")["type"], "other");
}
#[tokio::test]
async fn create_song_in_directory_with_name_override() {
let (state, _dir) = test_state();
let song_dir = _dir.path().join("songs").join("override_test");
std::fs::create_dir_all(&song_dir).unwrap();
let wav_bytes = create_test_wav();
std::fs::write(song_dir.join("track.wav"), &wav_bytes).unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/create-song")
.header("content-type", "application/json")
.body(Body::from(
r#"{"path": "/songs/override_test", "name": "Custom Name"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let config_content = std::fs::read_to_string(song_dir.join("song.yaml")).unwrap();
assert!(
config_content.contains("Custom Name"),
"song.yaml should contain the custom name, got: {}",
config_content
);
}
#[tokio::test]
async fn create_song_in_directory_nonexistent_path() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/create-song")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/nonexistent/dir"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn browse_directory_sorts_dirs_first() {
let (state, _dir) = test_state();
let subdir = _dir.path().join("sorttest");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("aaa_file.txt"), "data").unwrap();
std::fs::create_dir(subdir.join("zzz_dir")).unwrap();
std::fs::write(subdir.join("bbb_file.wav"), create_test_wav()).unwrap();
std::fs::create_dir(subdir.join("aaa_dir")).unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/browse?path=/sorttest")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let entries = parsed["entries"].as_array().unwrap();
assert_eq!(entries.len(), 4);
assert!(entries[0]["is_dir"].as_bool().unwrap());
assert!(entries[1]["is_dir"].as_bool().unwrap());
assert!(!entries[2]["is_dir"].as_bool().unwrap());
assert!(!entries[3]["is_dir"].as_bool().unwrap());
assert_eq!(entries[0]["name"], "aaa_dir");
assert_eq!(entries[1]["name"], "zzz_dir");
}
#[tokio::test]
async fn bulk_import_creates_songs() {
let (state, _dir) = test_state();
let parent = _dir.path().join("songs");
std::fs::create_dir_all(&parent).unwrap();
let song_a = parent.join("Alpha");
std::fs::create_dir_all(&song_a).unwrap();
std::fs::write(song_a.join("track.wav"), create_test_wav()).unwrap();
let song_b = parent.join("Beta");
std::fs::create_dir_all(&song_b).unwrap();
std::fs::write(song_b.join("track.wav"), create_test_wav()).unwrap();
let song_c = parent.join("Gamma");
std::fs::create_dir_all(&song_c).unwrap();
let song_d = parent.join("Delta");
std::fs::create_dir_all(&song_d).unwrap();
std::fs::write(song_d.join("track.wav"), create_test_wav()).unwrap();
std::fs::write(song_d.join("song.yaml"), "name: Delta\ntracks: []\n").unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/bulk-import")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/songs"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let created: Vec<&str> = parsed["created"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
let skipped: Vec<&str> = parsed["skipped"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
created.contains(&"Alpha"),
"Alpha should be created: {:?}",
created
);
assert!(
created.contains(&"Beta"),
"Beta should be created: {:?}",
created
);
assert!(
skipped.contains(&"Delta"),
"Delta should be skipped (existing): {:?}",
skipped
);
assert!(
!created.iter().any(|s| s.contains("Gamma")),
"Gamma should not be created (no audio): {:?}",
created
);
assert!(song_a.join("song.yaml").exists());
assert!(song_b.join("song.yaml").exists());
assert!(!song_c.join("song.yaml").exists());
}
#[tokio::test]
async fn bulk_import_nonexistent_path() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/bulk-import")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/nonexistent"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn bulk_import_empty_directory() {
let (state, _dir) = test_state();
let parent = _dir.path().join("empty_parent");
std::fs::create_dir_all(&parent).unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/bulk-import")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/empty_parent"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed["created"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn bulk_import_recursive_nested() {
let (state, _dir) = test_state();
let parent = _dir.path().join("music");
std::fs::create_dir_all(&parent).unwrap();
let song_deep = parent.join("Artist").join("Album").join("Track1");
std::fs::create_dir_all(&song_deep).unwrap();
std::fs::write(song_deep.join("track.wav"), create_test_wav()).unwrap();
let song_shallow = parent.join("MySong");
std::fs::create_dir_all(&song_shallow).unwrap();
std::fs::write(song_shallow.join("audio.wav"), create_test_wav()).unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/browse/bulk-import")
.header("content-type", "application/json")
.body(Body::from(r#"{"path": "/music"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let created: Vec<&str> = parsed["created"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
created.iter().any(|s| s.contains("Track1")),
"Nested Track1 should be created: {:?}",
created
);
assert!(
created.iter().any(|s| s.contains("MySong")),
"MySong should be created: {:?}",
created
);
assert!(song_deep.join("song.yaml").exists());
assert!(song_shallow.join("song.yaml").exists());
assert!(!parent.join("Artist").join("song.yaml").exists());
assert!(!parent
.join("Artist")
.join("Album")
.join("song.yaml")
.exists());
}
}