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); use crate::ingest::mover::MoveOutcome; let (storage_path, was_merged) = if dest.exists() && !source.exists() { // File already moved (e.g. auto-approved earlier but DB not finalized) (dest.to_string_lossy().to_string(), false) } else { match crate::ingest::mover::move_to_storage( &state.config.storage_dir, artist, album, &filename, source, ).await { Ok(MoveOutcome::Moved(p)) => (p.to_string_lossy().to_string(), false), Ok(MoveOutcome::Merged(p)) => (p.to_string_lossy().to_string(), true), 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) => { if was_merged { let _ = db::update_pending_status(&state.pool, id, "merged", None).await; } (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); use crate::ingest::mover::MoveOutcome; let (rel_path, was_merged) = if dest.exists() && !source.exists() { (dest.to_string_lossy().to_string(), false) } else { match crate::ingest::mover::move_to_storage( &state.config.storage_dir, artist, album, &filename, source, ).await { Ok(MoveOutcome::Moved(p)) => (p.to_string_lossy().to_string(), false), Ok(MoveOutcome::Merged(p)) => (p.to_string_lossy().to_string(), true), Err(e) => { errors.push(format!("{}: {}", id, e)); continue; } } }; match db::approve_and_finalize(&state.pool, *id, &rel_path).await { Ok(_) => { if was_merged { let _ = db::update_pending_status(&state.pool, *id, "merged", None).await; } 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()), } } // --- Merges --- #[derive(Deserialize)] pub struct CreateMergeBody { pub artist_ids: Vec, } pub async fn create_merge(State(state): State, Json(body): Json) -> impl IntoResponse { if body.artist_ids.len() < 2 { return error_response(StatusCode::BAD_REQUEST, "need at least 2 artists to merge"); } match db::insert_artist_merge(&state.pool, &body.artist_ids).await { Ok(id) => (StatusCode::OK, Json(serde_json::json!({"id": id}))).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn list_merges(State(state): State) -> impl IntoResponse { match db::list_artist_merges(&state.pool).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_merge(State(state): State, Path(id): Path) -> impl IntoResponse { let merge = match db::get_artist_merge(&state.pool, id).await { Ok(Some(m)) => m, Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), }; let source_ids: Vec = serde_json::from_str(&merge.source_artist_ids).unwrap_or_default(); let artists = match db::get_artists_full_data(&state.pool, &source_ids).await { Ok(a) => a, Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), }; let proposal: Option = merge.proposal.as_deref() .and_then(|p| serde_json::from_str(p).ok()); (StatusCode::OK, Json(serde_json::json!({ "merge": { "id": merge.id, "status": merge.status, "source_artist_ids": source_ids, "llm_notes": merge.llm_notes, "error_message": merge.error_message, "created_at": merge.created_at, "updated_at": merge.updated_at, }, "artists": artists, "proposal": proposal, }))).into_response() } #[derive(Deserialize)] pub struct UpdateMergeBody { pub proposal: serde_json::Value, } pub async fn update_merge( State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { let notes = body.proposal.get("notes") .and_then(|v| v.as_str()) .unwrap_or("") .to_owned(); let proposal_json = match serde_json::to_string(&body.proposal) { Ok(s) => s, Err(e) => return error_response(StatusCode::BAD_REQUEST, &e.to_string()), }; match db::update_merge_proposal(&state.pool, id, &proposal_json, ¬es).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn approve_merge(State(state): State, Path(id): Path) -> impl IntoResponse { match crate::merge::execute_merge(&state, id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => { let msg = e.to_string(); let _ = db::update_merge_status(&state.pool, id, "error", Some(&msg)).await; error_response(StatusCode::INTERNAL_SERVER_ERROR, &msg) } } } pub async fn reject_merge(State(state): State, Path(id): Path) -> impl IntoResponse { match db::update_merge_status(&state.pool, id, "rejected", None).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn retry_merge(State(state): State, Path(id): Path) -> impl IntoResponse { match db::update_merge_status(&state.pool, id, "pending", None).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), 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() }