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) }; match crate::ingest::mover::move_to_storage( &state.config.storage_dir, artist, album, &filename, source, ) .await { Ok(storage_path) => { let rel_path = storage_path.to_string_lossy().to_string(); match db::approve_and_finalize(&state.pool, id, &rel_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()), } } 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()), } } // --- 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() }