use std::sync::Arc; use axum::{ extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Json}, }; use serde::Deserialize; use uuid::Uuid; use crate::db; use super::AppState; type S = Arc; // --- Stats --- pub async fn stats(State(state): State) -> impl IntoResponse { match db::get_stats(&state.pool).await { Ok(stats) => (StatusCode::OK, Json(serde_json::to_value(stats).unwrap())).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Queue --- #[derive(Deserialize)] pub struct QueueQuery { #[serde(default)] pub status: Option, #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_limit() -> i64 { 50 } pub async fn list_queue(State(state): State, Query(q): Query) -> impl IntoResponse { match db::list_pending(&state.pool, q.status.as_deref(), q.limit, q.offset).await { Ok(items) => (StatusCode::OK, Json(serde_json::to_value(items).unwrap())).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn get_queue_item(State(state): State, Path(id): Path) -> impl IntoResponse { match db::get_pending(&state.pool, id).await { Ok(Some(item)) => (StatusCode::OK, Json(serde_json::to_value(item).unwrap())).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn delete_queue_item(State(state): State, Path(id): Path) -> impl IntoResponse { match db::delete_pending(&state.pool, id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn approve_queue_item(State(state): State, Path(id): Path) -> impl IntoResponse { // Get pending track, move file, finalize in DB let pt = match db::get_pending(&state.pool, id).await { Ok(Some(pt)) => pt, Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), }; let artist = pt.norm_artist.as_deref().unwrap_or("Unknown Artist"); let album = pt.norm_album.as_deref().unwrap_or("Unknown Album"); let title = pt.norm_title.as_deref().unwrap_or("Unknown Title"); let source = std::path::Path::new(&pt.inbox_path); let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("flac"); let track_num = pt.norm_track_number.unwrap_or(0); let filename = if track_num > 0 { format!("{:02} - {}.{}", track_num, sanitize_filename(title), ext) } else { format!("{}.{}", sanitize_filename(title), ext) }; let artist_dir = sanitize_filename(artist); let album_dir = sanitize_filename(album); let dest = state.config.storage_dir.join(&artist_dir).join(&album_dir).join(&filename); let storage_path = if dest.exists() && !source.exists() { // File already moved (e.g. auto-approved earlier but DB not finalized) dest.to_string_lossy().to_string() } else { match crate::ingest::mover::move_to_storage( &state.config.storage_dir, artist, album, &filename, source, ).await { Ok(p) => p.to_string_lossy().to_string(), Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } }; match db::approve_and_finalize(&state.pool, id, &storage_path).await { Ok(track_id) => (StatusCode::OK, Json(serde_json::json!({"track_id": track_id}))).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn reject_queue_item(State(state): State, Path(id): Path) -> impl IntoResponse { match db::update_pending_status(&state.pool, id, "rejected", None).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct UpdateQueueItem { pub norm_title: Option, pub norm_artist: Option, pub norm_album: Option, pub norm_year: Option, pub norm_track_number: Option, pub norm_genre: Option, #[serde(default)] pub featured_artists: Vec, } pub async fn update_queue_item( State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { let norm = db::NormalizedFields { title: body.norm_title, artist: body.norm_artist, album: body.norm_album, year: body.norm_year, track_number: body.norm_track_number, genre: body.norm_genre, featured_artists: body.featured_artists, confidence: Some(1.0), // manual edit = full confidence notes: Some("Manually edited".to_owned()), }; match db::update_pending_normalized(&state.pool, id, "review", &norm, None).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Retry --- pub async fn retry_queue_item(State(state): State, Path(id): Path) -> impl IntoResponse { match db::update_pending_status(&state.pool, id, "pending", None).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Batch operations --- #[derive(Deserialize)] pub struct BatchIds { pub ids: Vec, } pub async fn batch_approve(State(state): State, Json(body): Json) -> impl IntoResponse { let mut ok = 0u32; let mut errors = Vec::new(); for id in &body.ids { let pt = match db::get_pending(&state.pool, *id).await { Ok(Some(pt)) => pt, Ok(None) => { errors.push(format!("{}: not found", id)); continue; } Err(e) => { errors.push(format!("{}: {}", id, e)); continue; } }; let artist = pt.norm_artist.as_deref().unwrap_or("Unknown Artist"); let album = pt.norm_album.as_deref().unwrap_or("Unknown Album"); let title = pt.norm_title.as_deref().unwrap_or("Unknown Title"); let source = std::path::Path::new(&pt.inbox_path); let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("flac"); let track_num = pt.norm_track_number.unwrap_or(0); let filename = if track_num > 0 { format!("{:02} - {}.{}", track_num, sanitize_filename(title), ext) } else { format!("{}.{}", sanitize_filename(title), ext) }; let artist_dir = sanitize_filename(artist); let album_dir = sanitize_filename(album); let dest = state.config.storage_dir.join(&artist_dir).join(&album_dir).join(&filename); let rel_path = if dest.exists() && !source.exists() { dest.to_string_lossy().to_string() } else { match crate::ingest::mover::move_to_storage( &state.config.storage_dir, artist, album, &filename, source, ).await { Ok(p) => p.to_string_lossy().to_string(), Err(e) => { errors.push(format!("{}: {}", id, e)); continue; } } }; match db::approve_and_finalize(&state.pool, *id, &rel_path).await { Ok(_) => ok += 1, Err(e) => errors.push(format!("{}: {}", id, e)), } } (StatusCode::OK, Json(serde_json::json!({"approved": ok, "errors": errors}))).into_response() } pub async fn batch_reject(State(state): State, Json(body): Json) -> impl IntoResponse { let mut ok = 0u32; for id in &body.ids { if db::update_pending_status(&state.pool, *id, "rejected", None).await.is_ok() { ok += 1; } } (StatusCode::OK, Json(serde_json::json!({"rejected": ok}))).into_response() } pub async fn batch_retry(State(state): State, Json(body): Json) -> impl IntoResponse { let mut ok = 0u32; for id in &body.ids { if db::update_pending_status(&state.pool, *id, "pending", None).await.is_ok() { ok += 1; } } (StatusCode::OK, Json(serde_json::json!({"retried": ok}))).into_response() } pub async fn batch_delete(State(state): State, Json(body): Json) -> impl IntoResponse { let mut ok = 0u32; for id in &body.ids { if db::delete_pending(&state.pool, *id).await.unwrap_or(false) { ok += 1; } } (StatusCode::OK, Json(serde_json::json!({"deleted": ok}))).into_response() } // --- Artists --- #[derive(Deserialize)] pub struct SearchArtistsQuery { pub q: String, #[serde(default = "default_search_limit")] pub limit: i32, } fn default_search_limit() -> i32 { 10 } pub async fn search_artists(State(state): State, Query(q): Query) -> impl IntoResponse { if q.q.is_empty() { return (StatusCode::OK, Json(serde_json::json!([]))).into_response(); } match db::find_similar_artists(&state.pool, &q.q, q.limit).await { Ok(artists) => (StatusCode::OK, Json(serde_json::to_value(artists).unwrap())).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn list_artists(State(state): State) -> impl IntoResponse { match db::list_artists_all(&state.pool).await { Ok(artists) => (StatusCode::OK, Json(serde_json::to_value(artists).unwrap())).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct UpdateArtistBody { pub name: String, } pub async fn update_artist( State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { match db::update_artist_name(&state.pool, id, &body.name).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Albums --- pub async fn list_albums(State(state): State, Path(artist_id): Path) -> impl IntoResponse { match db::list_albums_by_artist(&state.pool, artist_id).await { Ok(albums) => (StatusCode::OK, Json(serde_json::to_value(albums).unwrap())).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct UpdateAlbumBody { pub name: String, pub year: Option, } pub async fn update_album( State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { match db::update_album(&state.pool, id, &body.name, body.year).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Helpers --- fn error_response(status: StatusCode, message: &str) -> axum::response::Response { (status, Json(serde_json::json!({"error": message}))).into_response() } fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', _ => c, }) .collect::() .trim() .to_owned() }