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, release_type: None, 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()), } } // --- Library search --- #[derive(Deserialize)] pub struct LibraryQuery { #[serde(default)] pub q: String, #[serde(default)] pub artist: String, #[serde(default)] pub album: String, #[serde(default = "default_lib_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_lib_limit() -> i64 { 50 } pub async fn library_tracks(State(state): State, Query(q): Query) -> impl IntoResponse { let (tracks, total) = tokio::join!( db::search_tracks(&state.pool, &q.q, &q.artist, &q.album, q.limit, q.offset), db::count_tracks(&state.pool, &q.q, &q.artist, &q.album), ); match (tracks, total) { (Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(), (Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn library_albums(State(state): State, Query(q): Query) -> impl IntoResponse { let (albums, total) = tokio::join!( db::search_albums(&state.pool, &q.q, &q.artist, q.limit, q.offset), db::count_albums(&state.pool, &q.q, &q.artist), ); match (albums, total) { (Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(), (Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn library_artists(State(state): State, Query(q): Query) -> impl IntoResponse { let (artists, total) = tokio::join!( db::search_artists_lib(&state.pool, &q.q, q.limit, q.offset), db::count_artists_lib(&state.pool, &q.q), ); match (artists, total) { (Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(), (Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Track / Album detail & edit --- pub async fn get_track(State(state): State, Path(id): Path) -> impl IntoResponse { match db::get_track_full(&state.pool, id).await { Ok(Some(t)) => (StatusCode::OK, Json(serde_json::to_value(t).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 update_track( State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { match db::update_track_metadata(&state.pool, id, &body).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn get_album_full(State(state): State, Path(id): Path) -> impl IntoResponse { match db::get_album_details(&state.pool, id).await { Ok(Some(a)) => (StatusCode::OK, Json(serde_json::to_value(a).unwrap())).into_response(), Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct AlbumUpdateBody { pub name: String, pub year: Option, pub artist_id: i64, } pub async fn update_album_full( State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { match db::update_album_full(&state.pool, id, &body.name, body.year, body.artist_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct ReorderBody { pub orders: Vec<(i64, i32)>, } pub async fn reorder_album_tracks( State(state): State, Path(_id): Path, Json(body): Json, ) -> impl IntoResponse { match db::reorder_tracks(&state.pool, &body.orders).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn album_cover(State(state): State, Path(id): Path) -> impl IntoResponse { let cover = match db::get_album_cover(&state.pool, id).await { Ok(Some(c)) => c, Ok(None) => return StatusCode::NOT_FOUND.into_response(), Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), }; match tokio::fs::read(&cover.0).await { Ok(bytes) => ( [(axum::http::header::CONTENT_TYPE, cover.1)], bytes, ).into_response(), Err(_) => StatusCode::NOT_FOUND.into_response(), } } #[derive(Deserialize)] pub struct AlbumSearchQuery { #[serde(default)] pub q: String, pub artist_id: Option, } pub async fn search_albums_for_artist(State(state): State, Query(q): Query) -> impl IntoResponse { match db::search_albums_for_artist(&state.pool, &q.q, q.artist_id).await { Ok(items) => (StatusCode::OK, Json(serde_json::to_value( items.iter().map(|(id, name)| serde_json::json!({"id": id, "name": name})).collect::>() ).unwrap())).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } // --- Artist full admin form --- pub async fn get_artist_full(State(state): State, Path(id): Path) -> impl IntoResponse { let artist = match db::get_artist_by_id(&state.pool, id).await { Ok(Some(a)) => a, Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"), Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), }; let (albums, appearances) = tokio::join!( db::get_artist_albums(&state.pool, id), db::get_artist_appearances(&state.pool, id), ); // For each album, load tracks let albums = match albums { Ok(a) => a, Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), }; let mut albums_with_tracks = Vec::new(); for album in albums { let tracks = db::get_album_tracks_admin(&state.pool, album.id).await.unwrap_or_default(); albums_with_tracks.push(serde_json::json!({ "id": album.id, "name": album.name, "year": album.year, "release_type": album.release_type, "hidden": album.hidden, "track_count": album.track_count, "tracks": tracks, })); } (StatusCode::OK, Json(serde_json::json!({ "artist": artist, "albums": albums_with_tracks, "appearances": appearances.unwrap_or_default(), }))).into_response() } #[derive(Deserialize)] pub struct SetHiddenBody { pub hidden: bool } pub async fn set_track_hidden(State(state): State, Path(id): Path, Json(b): Json) -> impl IntoResponse { match db::set_track_hidden(&state.pool, id, b.hidden).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn set_album_hidden(State(state): State, Path(id): Path, Json(b): Json) -> impl IntoResponse { match db::set_album_hidden(&state.pool, id, b.hidden).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn set_artist_hidden(State(state): State, Path(id): Path, Json(b): Json) -> impl IntoResponse { match db::set_artist_hidden(&state.pool, id, b.hidden).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct SetReleaseTypeBody { pub release_type: String } pub async fn set_album_release_type(State(state): State, Path(id): Path, Json(b): Json) -> impl IntoResponse { let valid = ["album","single","ep","compilation","live"]; if !valid.contains(&b.release_type.as_str()) { return error_response(StatusCode::BAD_REQUEST, "invalid release_type"); } match db::set_album_release_type(&state.pool, id, &b.release_type).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct RenameArtistBody { pub name: String } pub async fn rename_artist_api(State(state): State, Path(id): Path, Json(b): Json) -> impl IntoResponse { match db::rename_artist_name(&state.pool, id, &b.name).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct AddAppearanceBody { pub track_id: i64 } pub async fn add_appearance(State(state): State, Path(artist_id): Path, Json(b): Json) -> impl IntoResponse { match db::add_track_appearance(&state.pool, b.track_id, artist_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } pub async fn remove_appearance(State(state): State, Path((artist_id, track_id)): Path<(i64, i64)>) -> impl IntoResponse { match db::remove_track_appearance(&state.pool, track_id, artist_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[derive(Deserialize)] pub struct SearchTracksQuery { #[serde(default)] pub q: String } pub async fn search_tracks_feat(State(state): State, Query(q): Query) -> impl IntoResponse { match db::search_tracks_for_feat(&state.pool, &q.q).await { Ok(rows) => (StatusCode::OK, Json(serde_json::to_value( rows.iter().map(|(id, title, artist)| serde_json::json!({"id": id, "title": title, "artist_name": artist})).collect::>() ).unwrap())).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() }