use axum::{
body::Bytes,
extract::{Multipart, Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde_json::json;
use super::super::config_io;
use super::super::server::WebUiState;
use super::super::state as ws_state;
use crate::{config, songs};
pub(super) async fn get_songs(State(state): State<WebUiState>) -> impl IntoResponse {
let all_songs = state.player.songs();
let song_list: Vec<serde_json::Value> = all_songs
.sorted_list()
.iter()
.map(|song| {
let base_dir = song
.base_path()
.strip_prefix(&state.songs_path)
.unwrap_or(std::path::Path::new(""))
.to_string_lossy()
.to_string();
let lighting_files: Vec<String> = song
.dsl_lighting_shows()
.iter()
.filter_map(|show| {
show.file_path()
.strip_prefix(&state.songs_path)
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.collect();
let midi_dmx_files: Vec<String> = song
.light_shows()
.iter()
.filter_map(|show| {
show.dmx_file_path()
.strip_prefix(&state.songs_path)
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.collect();
json!({
"name": song.name(),
"duration_ms": song.duration().as_millis() as u64,
"duration_display": song.duration_string(),
"num_channels": song.num_channels(),
"sample_format": format!("{}", song.sample_format()),
"track_count": song.tracks().len(),
"tracks": song.tracks().iter().map(|t| t.name().to_string()).collect::<Vec<_>>(),
"has_midi": song.midi_playback().is_some(),
"has_lighting": !song.light_shows().is_empty() || !song.dsl_lighting_shows().is_empty(),
"base_dir": base_dir,
"lighting_files": lighting_files,
"midi_dmx_files": midi_dmx_files,
"beat_grid": song.beat_grid().map(|g| json!({
"beats": g.beats,
"measure_starts": g.measure_starts,
})),
"loop_playback": song.loop_playback(),
"sections": song.sections().iter().map(|s| json!({
"name": s.name,
"start_measure": s.start_measure,
"end_measure": s.end_measure,
})).collect::<Vec<_>>(),
})
})
.collect();
let failure_list: Vec<serde_json::Value> = all_songs
.failures()
.iter()
.map(|f| {
let base_dir = f
.base_path()
.strip_prefix(&state.songs_path)
.unwrap_or(std::path::Path::new(""))
.to_string_lossy()
.to_string();
json!({
"name": f.name(),
"error": f.error(),
"base_dir": base_dir,
"failed": true,
})
})
.collect();
(
StatusCode::OK,
Json(json!({"songs": song_list, "failures": failure_list})),
)
.into_response()
}
fn find_yaml_path(base: &std::path::Path) -> Option<std::path::PathBuf> {
let yaml = base.join("song.yaml");
if yaml.exists() {
return Some(yaml);
}
let yml = base.join("song.yml");
if yml.exists() {
return Some(yml);
}
None
}
async fn read_yaml_response(path: std::path::PathBuf) -> axum::response::Response {
match super::helpers::spawn_blocking_io("read song config", move || {
std::fs::read_to_string(&path)
})
.await
{
Ok(content) => (
StatusCode::OK,
[("content-type", "text/yaml; charset=utf-8")],
content,
)
.into_response(),
Err(e) => e,
}
}
pub(super) async fn get_song(
State(state): State<WebUiState>,
Path(name): Path<String>,
) -> axum::response::Response {
enum GetSongResult {
YamlPath(std::path::PathBuf),
Summary(axum::response::Response),
NotFound(axum::response::Response),
}
let result = {
let all_songs = state.player.songs();
match all_songs.get(&name) {
Ok(song) => match find_yaml_path(song.base_path()) {
Some(path) => GetSongResult::YamlPath(path),
None => GetSongResult::Summary(
(
StatusCode::OK,
Json(json!({
"name": song.name(),
"duration_ms": song.duration().as_millis() as u64,
"duration_display": song.duration_string(),
"num_channels": song.num_channels(),
"sample_format": format!("{}", song.sample_format()),
"tracks": song.tracks().iter().map(|t| t.name().to_string()).collect::<Vec<_>>(),
"config_file": false,
})),
)
.into_response(),
),
},
Err(_) => {
if let Some(failure) = all_songs.failures().iter().find(|f| f.name() == name) {
match find_yaml_path(failure.base_path()) {
Some(path) => GetSongResult::YamlPath(path),
None => GetSongResult::NotFound(
(
StatusCode::NOT_FOUND,
Json(json!({"error": format!("No config file found for failed song: {}", name)})),
)
.into_response(),
),
}
} else {
GetSongResult::NotFound(
(
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Song not found: {}", name)})),
)
.into_response(),
)
}
}
}
};
match result {
GetSongResult::YamlPath(path) => read_yaml_response(path).await,
GetSongResult::Summary(resp) | GetSongResult::NotFound(resp) => resp,
}
}
pub(super) async fn import_file_to_song(
State(state): State<WebUiState>,
Path(name): Path<String>,
Json(body): Json<ImportFileRequest>,
) -> impl IntoResponse {
use super::super::safe_path::{SafePath, VerifiedRoot};
let config_canonical = match state.config_path.canonicalize() {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to resolve config path: {}", e)})),
)
.into_response();
}
};
let project_root = match config_canonical.parent() {
Some(p) => match VerifiedRoot::new(p) {
Ok(r) => r,
Err(e) => return e.into_response(),
},
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Unable to determine project root"})),
)
.into_response();
}
};
let source = match SafePath::resolve(std::path::Path::new(&body.path), &project_root) {
Ok(p) => p,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Source file does not exist or is outside the project directory"})),
)
.into_response();
}
};
if !source.is_file() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Source path is not a file"})),
)
.into_response();
}
let filename = body
.filename
.as_deref()
.or_else(|| source.file_name().and_then(|n| n.to_str()))
.unwrap_or("unknown");
if let Err(e) = validate_track_filename(filename) {
return *e;
}
let song_dir = match resolve_or_create_song_dir(&state.player, &state.songs_path, &name) {
Ok(d) => d,
Err(e) => return *e,
};
let dest_path = song_dir.join_filename(filename);
let src = source.as_path().to_path_buf();
let dst = dest_path.clone();
if let Err(e) =
super::helpers::spawn_blocking_io("copy file", move || std::fs::copy(&src, &dst)).await
{
return e;
}
if let Err(e) = ensure_song_config(&song_dir) {
return *e;
}
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
(
StatusCode::OK,
Json(json!({
"status": "imported",
"file": filename,
"song": name,
})),
)
.into_response()
}
#[derive(serde::Deserialize)]
pub(super) struct ImportFileRequest {
path: String,
filename: Option<String>,
}
pub(super) async fn delete_song(
State(state): State<WebUiState>,
Path(name): Path<String>,
) -> impl IntoResponse {
if state.player.is_playing().await {
if let Some(current) = state.player.get_playlist().current() {
if current.name() == name {
return (
StatusCode::CONFLICT,
Json(json!({"error": "Cannot delete a song that is currently playing"})),
)
.into_response();
}
}
}
let song_dir = match resolve_song_dir(&state.player, &state.songs_path, &name) {
Ok(p) => p,
Err(e) => return *e,
};
let config_path = song_dir.join_filename("song.yaml");
if !config_path.exists() {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "song.yaml not found for this song"})),
)
.into_response();
}
let cp = config_path.clone();
let song_name = name.clone();
let pl_dir = state.playlists_dir.clone();
let legacy_pl = state.legacy_playlist_path.clone();
if let Err(e) = super::helpers::spawn_blocking_io("delete song", move || {
std::fs::remove_file(&cp)?;
remove_song_from_playlists(&song_name, pl_dir.as_deref(), legacy_pl.as_deref());
Ok::<_, std::io::Error>(())
})
.await
{
return e;
}
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
(
StatusCode::OK,
Json(json!({"status": "deleted", "name": name})),
)
.into_response()
}
pub(super) async fn get_song_waveform(
State(state): State<WebUiState>,
Path(name): Path<String>,
) -> impl IntoResponse {
{
let cache = state.waveform_cache.lock();
if let Some(cached) = cache.get(&name) {
let tracks: Vec<serde_json::Value> = cached
.iter()
.map(|(track_name, peaks)| json!({ "name": track_name, "peaks": peaks }))
.collect();
return (
StatusCode::OK,
Json(json!({ "song_name": name, "tracks": tracks })),
)
.into_response();
}
}
let all_songs = state.player.songs();
let song = match all_songs.get(&name) {
Ok(s) => s,
Err(_) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Song not found: {}", name)})),
)
.into_response();
}
};
let track_infos: Vec<ws_state::TrackInfo> = song
.tracks()
.iter()
.map(|t| {
(
t.name().to_string(),
t.file().to_path_buf(),
t.file_channel(),
)
})
.collect();
let song_dir = song.base_path().to_path_buf();
let cache = state.waveform_cache.clone();
let song_name = name.clone();
let peaks_result = tokio::task::spawn_blocking(move || {
let peaks = ws_state::compute_waveform_peaks(&song_dir, &track_infos);
cache.lock().insert(song_name, peaks.clone());
peaks
})
.await;
match peaks_result {
Ok(peaks) => {
let tracks: Vec<serde_json::Value> = peaks
.iter()
.map(|(track_name, p)| json!({ "name": track_name, "peaks": p }))
.collect();
(
StatusCode::OK,
Json(json!({ "song_name": name, "tracks": tracks })),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to compute waveform: {}", e)})),
)
.into_response(),
}
}
pub(super) async fn get_song_tempo_guess(
State(state): State<WebUiState>,
Path(name): Path<String>,
) -> impl IntoResponse {
let all_songs = state.player.songs();
let song = match all_songs.get(&name) {
Ok(s) => s,
Err(_) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Song not found: {}", name)})),
)
.into_response();
}
};
if let Some(midi_playback) = song.midi_playback() {
if let Some(guessed) = crate::audio::midi_tempo::extract_tempo_from_midi(
midi_playback.file_path(),
song.beat_grid(),
) {
return (
StatusCode::OK,
Json(json!({
"source": "midi",
"tempo": guessed,
})),
)
.into_response();
}
}
let beat_grid = match song.beat_grid() {
Some(bg) => bg,
None => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "Song has no MIDI file or beat grid"})),
)
.into_response();
}
};
match crate::audio::tempo_guess::guess_tempo(beat_grid) {
Some(guessed) => (
StatusCode::OK,
Json(json!({
"source": "beat_grid",
"tempo": guessed,
})),
)
.into_response(),
None => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({"error": "Beat grid has too few beats to estimate tempo"})),
)
.into_response(),
}
}
pub(super) async fn get_song_files(
State(state): State<WebUiState>,
Path(name): Path<String>,
) -> impl IntoResponse {
let all_songs = state.player.songs();
let song = match all_songs.get(&name) {
Ok(s) => s,
Err(_) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Song not found: {}", name)})),
)
.into_response();
}
};
let song_dir = song.base_path().to_path_buf();
let mut files = match super::helpers::spawn_blocking_io("read song dir", move || {
let mut files: Vec<serde_json::Value> = Vec::new();
for entry in std::fs::read_dir(&song_dir)?.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if filename == "song.yaml" || filename == "song.yml" {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let file_type = if songs::is_supported_audio_extension(&ext) {
"audio"
} else if ext == "mid" {
"midi"
} else if ext == "light" {
"lighting"
} else {
"other"
};
files.push(json!({
"name": filename,
"type": file_type,
}));
}
Ok::<_, std::io::Error>(files)
})
.await
{
Ok(f) => f,
Err(e) => return e,
};
files.sort_by(|a, b| {
a.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.cmp(b.get("name").and_then(|v| v.as_str()).unwrap_or(""))
});
(StatusCode::OK, Json(json!({"files": files}))).into_response()
}
pub(super) async fn post_song(
State(state): State<WebUiState>,
Path(name): Path<String>,
body: String,
) -> impl IntoResponse {
use super::super::safe_path::{SafePath, VerifiedRoot};
let root = match VerifiedRoot::new(&state.songs_path) {
Ok(r) => r,
Err(e) => return e.into_response(),
};
if let Err(e) = validate_song_body(&body) {
return e;
}
let song_dir = match SafePath::create_dir_nested(&name, &root) {
Ok(p) => p,
Err(e) => return e.into_response(),
};
let config_path = song_dir.join_filename("song.yaml");
if config_path.exists() {
return (
StatusCode::CONFLICT,
Json(json!({"error": format!("Song already exists: {}", name)})),
)
.into_response();
}
let cp = config_path;
let body_owned = body;
if let Err(e) = super::helpers::spawn_blocking_io("write song config", move || {
config_io::atomic_write(&cp, &body_owned)
})
.await
{
return e;
}
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
(
StatusCode::CREATED,
Json(json!({"status": "created", "song": name})),
)
.into_response()
}
pub(super) async fn put_song(
State(state): State<WebUiState>,
Path(name): Path<String>,
body: String,
) -> impl IntoResponse {
let song_dir = match resolve_song_dir(&state.player, &state.songs_path, &name) {
Ok(p) => p,
Err(e) => return *e,
};
let config_path = song_dir.join_filename("song.yaml");
if let Err(e) = validate_song_body(&body) {
return e;
}
let cp = config_path;
let body_owned = body;
if let Err(e) = super::helpers::spawn_blocking_io("write song config", move || {
config_io::atomic_write(&cp, &body_owned)
})
.await
{
return e;
}
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
(StatusCode::OK, Json(json!({"status": "saved"}))).into_response()
}
pub(super) async fn upload_track_single(
State(state): State<WebUiState>,
Path((name, filename)): Path<(String, String)>,
body: Bytes,
) -> impl IntoResponse {
validate_track_filename(&filename).map_err(|e| *e)?;
let song_dir =
resolve_or_create_song_dir(&state.player, &state.songs_path, &name).map_err(|e| *e)?;
let file_path = song_dir.join_filename(&filename);
let replaced = file_path.exists();
let fp = file_path.clone();
let data = body.to_vec();
super::helpers::spawn_blocking_io("write track", move || std::fs::write(&fp, &data)).await?;
ensure_song_config(&song_dir).map_err(|e| *e)?;
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
Ok::<_, axum::response::Response>((
StatusCode::OK,
Json(json!({
"status": if replaced { "replaced" } else { "uploaded" },
"replaced": replaced,
"song": name,
"file": filename,
})),
))
}
pub(super) async fn upload_tracks_multipart(
State(state): State<WebUiState>,
Path(name): Path<String>,
mut multipart: Multipart,
) -> impl IntoResponse {
let song_dir =
resolve_or_create_song_dir(&state.player, &state.songs_path, &name).map_err(|e| *e)?;
let mut uploaded: Vec<String> = Vec::new();
while let Some(field) = multipart.next_field().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("Failed to read multipart field: {}", e)})),
)
.into_response()
})? {
let filename = match field.file_name() {
Some(name) => name.to_string(),
None => {
continue;
}
};
validate_track_filename(&filename).map_err(|e| *e)?;
let data = field.bytes().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("Failed to read file data: {}", e)})),
)
.into_response()
})?;
let file_path = song_dir.join_filename(&filename);
let fp = file_path.clone();
let data_vec = data.to_vec();
super::helpers::spawn_blocking_io("write track file", move || {
std::fs::write(&fp, &data_vec)
})
.await?;
uploaded.push(filename);
}
if uploaded.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"error": "No files uploaded"})),
)
.into_response());
}
ensure_song_config(&song_dir).map_err(|e| *e)?;
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
Ok((
StatusCode::OK,
Json(json!({
"status": "uploaded",
"song": name,
"files": uploaded,
})),
))
}
use super::super::safe_path::{SafePath, VerifiedRoot};
#[allow(clippy::result_large_err)]
fn validate_song_body(body: &str) -> Result<config::Song, axum::response::Response> {
let tmp = tempfile::Builder::new()
.suffix(".yaml")
.tempfile()
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to create temp file: {}", e)})),
)
.into_response()
})?;
std::fs::write(tmp.path(), body).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to write temp file: {}", e)})),
)
.into_response()
})?;
let song_config = config::Song::deserialize(tmp.path()).map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(json!({"errors": [format!("{}", e)]})),
)
.into_response()
})?;
if let Err(errors) = song_config.validate() {
return Err((StatusCode::BAD_REQUEST, Json(json!({"errors": errors}))).into_response());
}
Ok(song_config)
}
pub(super) fn resolve_song_dir(
player: &crate::player::Player,
songs_path: &std::path::Path,
name: &str,
) -> Result<SafePath, Box<axum::response::Response>> {
let root = VerifiedRoot::new(songs_path).map_err(|e| Box::new(e.into_response()))?;
if let Some(song) = player
.get_all_songs_playlist()
.and_then(|p| p.get_song(name))
{
if let Ok(safe) = SafePath::resolve(song.base_path(), &root) {
if safe.is_dir() {
return Ok(safe);
}
}
}
let all_songs = player.songs();
if let Some(failure) = all_songs.failures().iter().find(|f| f.name() == name) {
if let Ok(safe) = SafePath::resolve(failure.base_path(), &root) {
if safe.is_dir() {
return Ok(safe);
}
}
}
if let Ok(safe) = root.resolve(name) {
if safe.is_dir() {
return Ok(safe);
}
}
Err(Box::new(
(
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Song not found: {}", name)})),
)
.into_response(),
))
}
pub(super) fn resolve_or_create_song_dir(
player: &crate::player::Player,
songs_path: &std::path::Path,
name: &str,
) -> Result<SafePath, Box<axum::response::Response>> {
if let Ok(dir) = resolve_song_dir(player, songs_path, name) {
return Ok(dir);
}
let root = VerifiedRoot::new(songs_path).map_err(|e| Box::new(e.into_response()))?;
SafePath::create_dir(&root.as_safe_path(), name, &root).map_err(|e| Box::new(e.into_response()))
}
fn remove_song_from_playlists(
song_name: &str,
playlists_dir: Option<&std::path::Path>,
legacy_playlist_path: Option<&std::path::Path>,
) {
let mut files_to_check: Vec<std::path::PathBuf> = Vec::new();
if let Some(dir) = playlists_dir {
if dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
{
files_to_check.push(path);
}
}
}
}
}
if let Some(legacy) = legacy_playlist_path {
if legacy.is_file() {
files_to_check.push(legacy.to_path_buf());
}
}
for path in &files_to_check {
let Ok(playlist_config) = config::Playlist::deserialize(path) else {
continue;
};
let songs = playlist_config.songs();
if !songs.iter().any(|s| s == song_name) {
continue;
}
let filtered: Vec<String> = songs.iter().filter(|s| *s != song_name).cloned().collect();
let updated = config::Playlist::new(&filtered);
if let Ok(yaml) = crate::util::to_yaml_string(&updated) {
let _ = std::fs::write(path, yaml);
}
}
}
pub(super) fn ensure_song_config(
song_dir: &std::path::Path,
) -> Result<(), Box<axum::response::Response>> {
let config_path = song_dir.join("song.yaml");
if config_path.exists() {
return Ok(());
}
let song = songs::Song::initialize(&song_dir.to_path_buf()).map_err(|e| {
Box::new(
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to initialize song: {}", e)})),
)
.into_response(),
)
})?;
song.get_config().save(&config_path).map_err(|e| {
Box::new(
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to save song config: {}", e)})),
)
.into_response(),
)
})
}
pub(super) fn validate_track_filename(filename: &str) -> Result<(), Box<axum::response::Response>> {
if SafePath::validate_name(filename).is_err() {
return Err(Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "Invalid filename"})),
)
.into_response(),
));
}
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if ext != "mid" && ext != "light" && !songs::is_supported_audio_extension(ext) {
return Err(Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("Unsupported file type: .{}", ext)})),
)
.into_response(),
));
}
Ok(())
}
#[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 get_songs_empty_registry() {
let songs = std::sync::Arc::new(crate::songs::Songs::new(std::collections::HashMap::new()));
let (state, _dir) = test_state_with_registry(songs);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs")
.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["songs"].is_array());
assert!(parsed["songs"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn get_songs_returns_registry_contents() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs")
.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 songs = parsed["songs"].as_array().unwrap();
assert_eq!(songs.len(), 1);
assert_eq!(songs[0]["name"], "Song A");
}
#[tokio::test]
async fn get_song_not_found() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_song_missing_songs_dir() {
let (mut state, _dir) = test_state();
state.songs_path = std::path::PathBuf::from("/nonexistent/songs");
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/anything")
.body(Body::from("name: test\n"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn put_song_not_found() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/nonexistent")
.body(Body::from("name: test\n"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_songs_with_wav_files() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("TestSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: TestSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs")
.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 songs = parsed["songs"].as_array().unwrap();
assert_eq!(songs.len(), 1);
assert_eq!(songs[0]["name"], "TestSong");
assert!(songs[0]["tracks"].is_array());
}
#[tokio::test]
async fn get_song_with_config_file() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("MySong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
let song_yaml = "name: MySong\ntracks:\n - name: track1\n file: track1.wav\n";
std::fs::write(song_dir.join("song.yaml"), song_yaml).unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/MySong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body.contains("MySong"));
}
#[tokio::test]
async fn get_song_no_config_file_returns_json_summary() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("NoConfig");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: NoConfig\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
std::fs::rename(song_dir.join("song.yaml"), song_dir.join("song.bak")).unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/NoConfig")
.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_eq!(parsed["config_file"], false);
assert_eq!(parsed["name"], "NoConfig");
}
#[tokio::test]
async fn get_song_with_yml_extension() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("YmlSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
let song_yaml = "name: YmlSong\ntracks:\n - name: track1\n file: track1.wav\n";
std::fs::write(song_dir.join("song.yml"), song_yaml).unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/YmlSong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body.contains("YmlSong"));
}
#[tokio::test]
async fn put_song_valid() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("EditSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: EditSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
let new_yaml = "name: EditSong\ntracks:\n - name: track1\n file: track1.wav\n";
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/EditSong")
.body(Body::from(new_yaml))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn put_song_invalid_yaml() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("BadSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: BadSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/BadSong")
.body(Body::from("invalid yaml: [[["))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_song_no_config_yaml_returns_summary() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("CustomSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("config.yaml"),
"name: CustomSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/CustomSong")
.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_eq!(parsed["config_file"], false);
assert_eq!(parsed["name"], "CustomSong");
}
#[tokio::test]
async fn get_song_with_yml_config() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("YmlOnlySong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yml"),
"name: YmlOnlySong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/YmlOnlySong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
assert!(body.contains("YmlOnlySong"));
}
#[tokio::test]
async fn get_song_not_in_registry_returns_not_found() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed["error"].as_str().unwrap().contains("Song not found"));
}
#[tokio::test]
async fn get_song_not_found_body_contains_name() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/FakeSong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed["error"]
.as_str()
.unwrap()
.contains("Song not found: FakeSong"));
}
#[tokio::test]
async fn put_song_songs_dir_failure_returns_500() {
let (mut state, _dir) = test_state();
state.songs_path = std::path::PathBuf::from("/nonexistent/songs");
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/whatever")
.body(Body::from("name: whatever\n"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn put_song_not_found_body_contains_name() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/DoesNotExist")
.body(Body::from("name: DoesNotExist\n"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed["error"]
.as_str()
.unwrap()
.contains("Song not found: DoesNotExist"));
}
#[tokio::test]
async fn upload_track_single_creates_song() {
let (state, _dir) = test_state();
let app = router().with_state(state.clone());
let wav_bytes = create_test_wav();
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/NewSong/tracks/track1.wav")
.body(Body::from(wav_bytes))
.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_eq!(parsed["status"], "uploaded");
assert_eq!(parsed["song"], "NewSong");
assert_eq!(parsed["file"], "track1.wav");
assert!(state.songs_path.join("NewSong/track1.wav").exists());
assert!(state.songs_path.join("NewSong/song.yaml").exists());
}
#[tokio::test]
async fn upload_track_single_path_traversal_rejected() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/..%2F..%2Fetc/tracks/passwd")
.body(Body::from("bad"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn upload_track_single_unsupported_extension() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/TestSong/tracks/file.txt")
.body(Body::from("data"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_body(response).await;
assert!(body.contains("Unsupported file type"));
}
#[tokio::test]
async fn upload_tracks_multipart_creates_song() {
let (state, _dir) = test_state();
let app = router().with_state(state.clone());
let wav_bytes = create_test_wav();
let boundary = "----testboundary123";
let mut body_bytes = Vec::new();
body_bytes.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file1\"; filename=\"track1.wav\"\r\nContent-Type: application/octet-stream\r\n\r\n"
)
.as_bytes(),
);
body_bytes.extend_from_slice(&wav_bytes);
body_bytes.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/MultiSong/tracks")
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body_bytes))
.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_eq!(parsed["status"], "uploaded");
assert_eq!(parsed["song"], "MultiSong");
assert_eq!(parsed["files"][0], "track1.wav");
assert!(state.songs_path.join("MultiSong/track1.wav").exists());
assert!(state.songs_path.join("MultiSong/song.yaml").exists());
}
#[tokio::test]
async fn upload_tracks_multipart_empty_rejects() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let boundary = "----testboundary456";
let body_bytes = format!("--{boundary}--\r\n");
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/EmptySong/tracks")
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_body(response).await;
assert!(body.contains("No files uploaded"));
}
#[tokio::test]
async fn upload_track_single_adds_to_existing_song() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("ExistingSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
let app = router().with_state(state.clone());
let wav_bytes = create_test_wav();
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/ExistingSong/tracks/track2.wav")
.body(Body::from(wav_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(song_dir.join("track1.wav").exists());
assert!(song_dir.join("track2.wav").exists());
assert!(song_dir.join("song.yaml").exists());
}
#[tokio::test]
async fn post_song_creates_song() {
let (state, _dir) = test_state();
let app = router().with_state(state.clone());
let yaml = "name: Brand New Song\ntracks: []\n";
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/BrandNew")
.body(Body::from(yaml))
.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");
assert_eq!(parsed["song"], "BrandNew");
assert!(state.songs_path.join("BrandNew").is_dir());
assert!(state.songs_path.join("BrandNew/song.yaml").exists());
}
#[tokio::test]
async fn post_song_conflict_if_exists() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("Existing");
std::fs::create_dir(&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("/songs/Existing")
.body(Body::from("name: Existing\ntracks: []\n"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn post_song_invalid_yaml() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/BadSong")
.body(Body::from("not valid: [[["))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_song_then_put_updates_config() {
let (state, _dir) = test_state();
let app = router().with_state(state.clone());
let yaml = "name: MySong\ntracks: []\n";
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/MySong")
.body(Body::from(yaml))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let app = router().with_state(state.clone());
let updated_yaml = "name: MySong Renamed\ntracks: []\n";
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/MySong")
.body(Body::from(updated_yaml))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content = std::fs::read_to_string(state.songs_path.join("MySong/song.yaml")).unwrap();
assert!(content.contains("MySong Renamed"));
}
#[tokio::test]
async fn post_song_then_upload_preserves_config() {
let (state, _dir) = test_state();
let yaml = "name: My Custom Song\ntracks:\n - name: Lead Guitar\n file: guitar.wav\n";
let app = router().with_state(state.clone());
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/CustomSong")
.body(Body::from(yaml))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let wav_bytes = create_test_wav();
let app = router().with_state(state.clone());
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/CustomSong/tracks/guitar.wav")
.body(Body::from(wav_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content =
std::fs::read_to_string(state.songs_path.join("CustomSong/song.yaml")).unwrap();
assert!(content.contains("My Custom Song"));
assert!(content.contains("Lead Guitar"));
}
#[tokio::test]
async fn import_file_to_song_success() {
let (state, dir) = test_state();
let wav_bytes = create_test_wav();
let source_path = dir.path().join("import_me.wav");
std::fs::write(&source_path, &wav_bytes).unwrap();
let app = router().with_state(state.clone());
let body =
serde_json::json!({ "path": source_path.canonicalize().unwrap().to_str().unwrap() });
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/TestSong/import")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
assert_eq!(parsed["status"], "imported");
assert_eq!(parsed["file"], "import_me.wav");
assert_eq!(parsed["song"], "TestSong");
assert!(state.songs_path.join("TestSong/import_me.wav").exists());
}
#[tokio::test]
async fn import_file_to_song_outside_project_rejected() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let body = serde_json::json!({ "path": "/etc/hosts" });
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/TestSong/import")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let resp_body = response_body(response).await;
assert!(resp_body.contains("outside the project directory"));
}
#[tokio::test]
async fn import_file_to_song_nonexistent_source() {
let (state, dir) = test_state();
let app = router().with_state(state);
let nonexistent = dir.path().join("does_not_exist.wav");
let body = serde_json::json!({ "path": nonexistent.to_str().unwrap() });
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/TestSong/import")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let resp_body = response_body(response).await;
assert!(resp_body.contains("does not exist"));
}
#[tokio::test]
async fn import_file_to_song_renames_with_dmx_prefix() {
let (state, dir) = test_state();
let source_path = dir.path().join("original.mid");
let midi_bytes = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/song.mid"),
)
.unwrap();
std::fs::write(&source_path, &midi_bytes).unwrap();
let app = router().with_state(state.clone());
let body = serde_json::json!({
"path": source_path.canonicalize().unwrap().to_str().unwrap(),
"filename": "dmx_lightshow.mid"
});
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/TestSong/import")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
assert_eq!(parsed["file"], "dmx_lightshow.mid");
assert!(state.songs_path.join("TestSong/dmx_lightshow.mid").exists());
}
#[tokio::test]
async fn import_file_to_song_rejects_unsupported_extension() {
let (state, dir) = test_state();
let source_path = dir.path().join("notes.wav");
std::fs::write(&source_path, create_test_wav()).unwrap();
let app = router().with_state(state);
let body = serde_json::json!({
"path": source_path.canonicalize().unwrap().to_str().unwrap(),
"filename": "readme.txt"
});
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/TestSong/import")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let resp_body = response_body(response).await;
assert!(resp_body.contains("Unsupported file type"));
}
#[tokio::test]
async fn import_file_to_song_directory_rejected() {
let (state, dir) = test_state();
let subdir = dir.path().join("a_directory");
std::fs::create_dir(&subdir).unwrap();
let app = router().with_state(state);
let body = serde_json::json!({ "path": subdir.canonicalize().unwrap().to_str().unwrap() });
let response = app
.oneshot(
http::Request::builder()
.method("POST")
.uri("/songs/TestSong/import")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let resp_body = response_body(response).await;
assert!(resp_body.contains("not a file"));
}
#[tokio::test]
async fn get_songs_includes_base_dir() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("BaseDirSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: BaseDirSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs")
.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 songs = parsed["songs"].as_array().unwrap();
let base_dir_song = songs
.iter()
.find(|s| s["name"] == "BaseDirSong")
.expect("BaseDirSong should be in the response");
assert!(
base_dir_song.get("base_dir").is_some(),
"Response should include base_dir field"
);
assert_eq!(base_dir_song["base_dir"], "BaseDirSong");
}
#[tokio::test]
async fn get_songs_includes_lighting_files() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("LitSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(song_dir.join("show.light"), "show \"Test\" {\n}\n").unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: LitSong\ntracks:\n - name: track1\n file: track1.wav\nlighting:\n - file: show.light\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs")
.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 songs = parsed["songs"].as_array().unwrap();
let lit_song = songs
.iter()
.find(|s| s["name"] == "LitSong")
.expect("LitSong should be in the response");
let lighting_files = lit_song["lighting_files"].as_array().unwrap();
assert!(
!lighting_files.is_empty(),
"lighting_files should be populated"
);
assert!(
lighting_files[0].as_str().unwrap().contains("show.light"),
"lighting_files should contain the .light file path"
);
}
#[tokio::test]
async fn get_songs_includes_midi_dmx_files() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("LegacySong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
let midi_source =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/song.mid");
std::fs::copy(&midi_source, song_dir.join("dmx_show.mid")).unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: LegacySong\ntracks:\n - name: track1\n file: track1.wav\nlight_shows:\n - universe_name: default\n dmx_file: dmx_show.mid\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs")
.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 songs = parsed["songs"].as_array().unwrap();
let legacy_song = songs
.iter()
.find(|s| s["name"] == "LegacySong")
.expect("LegacySong should be in the response");
let legacy_files = legacy_song["midi_dmx_files"].as_array().unwrap();
assert!(
!legacy_files.is_empty(),
"midi_dmx_files should be populated"
);
assert!(
legacy_files[0].as_str().unwrap().contains("dmx_show.mid"),
"midi_dmx_files should contain the dmx MIDI file path"
);
}
#[tokio::test]
async fn get_song_waveform_success() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("WaveSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: WaveSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/WaveSong/waveform")
.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_eq!(parsed["song_name"], "WaveSong");
assert!(parsed["tracks"].is_array());
}
#[tokio::test]
async fn get_song_waveform_not_found() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/nonexistent/waveform")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_song_waveform_cached() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("CachedSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: CachedSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state.clone());
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/CachedSong/waveform")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/CachedSong/waveform")
.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_eq!(parsed["song_name"], "CachedSong");
assert!(parsed["tracks"].is_array());
}
#[tokio::test]
async fn get_song_files_success() {
let (state, _dir) = test_state();
let song_dir = state.songs_path.join("FilesSong");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(song_dir.join("track1.wav"), vec![vec![0_i32; 4410]], 44100)
.unwrap();
let midi_source =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/song.mid");
std::fs::copy(&midi_source, song_dir.join("notes.mid")).unwrap();
std::fs::write(song_dir.join("show.light"), "show \"Test\" {}\n").unwrap();
std::fs::write(song_dir.join("readme.txt"), "hello").unwrap();
std::fs::write(
song_dir.join("song.yaml"),
"name: FilesSong\ntracks:\n - name: track1\n file: track1.wav\n",
)
.unwrap();
state.player.reload_songs(
&state.songs_path,
state.playlists_dir.as_deref(),
state.legacy_playlist_path.as_deref(),
);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/FilesSong/files")
.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 files = parsed["files"].as_array().unwrap();
assert_eq!(files.len(), 4);
let find_file = |name: &str| files.iter().find(|f| f["name"] == name).unwrap();
assert_eq!(find_file("track1.wav")["type"], "audio");
assert_eq!(find_file("notes.mid")["type"], "midi");
assert_eq!(find_file("show.light")["type"], "lighting");
assert_eq!(find_file("readme.txt")["type"], "other");
}
#[tokio::test]
async fn get_song_files_not_found() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/songs/nonexistent/files")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn upload_track_single_midi_file() {
let (state, _dir) = test_state();
let app = router().with_state(state.clone());
let midi_bytes = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/song.mid"),
)
.unwrap();
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/MidiSong/tracks/notes.mid")
.body(Body::from(midi_bytes))
.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_eq!(parsed["status"], "uploaded");
assert_eq!(parsed["file"], "notes.mid");
assert!(state.songs_path.join("MidiSong/notes.mid").exists());
}
#[tokio::test]
async fn upload_track_single_light_file() {
let (state, _dir) = test_state();
let app = router().with_state(state.clone());
let light_content = b"show \"Test\" {}\n";
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/songs/LightSong/tracks/show.light")
.body(Body::from(light_content.as_slice()))
.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_eq!(parsed["status"], "uploaded");
assert_eq!(parsed["file"], "show.light");
assert!(state.songs_path.join("LightSong/show.light").exists());
}
}