use axum::extract::{Path, Query, State}; use axum::routing::{get, put}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use sqlx::{Sqlite, SqlitePool, Transaction}; use crate::error::{ApiError, ApiResult}; use crate::util::{RELEASE_TYPES, double_option, normalize_name, now_iso, page_limit, page_offset}; pub fn router() -> Router { Router::new() .route("/", get(list).post(create)) .route("/{id}", get(get_one).patch(update).delete(delete_one)) .route("/{id}/artists", put(set_artists)) } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct Release { pub id: i64, pub title: String, pub title_sort: String, pub release_type: String, pub year: Option, pub cover_file_id: Option, pub total_tracks: Option, pub total_discs: Option, pub is_hidden: bool, pub model_name: Option, pub created_at: String, pub updated_at: String, } pub async fn fetch(pool: &SqlitePool, id: i64) -> ApiResult { sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE id = ?") .bind(id) .fetch_optional(pool) .await? .ok_or_else(|| ApiError::not_found("release", id)) } fn validate_release_type(release_type: &str) -> ApiResult<()> { if RELEASE_TYPES.contains(&release_type) { Ok(()) } else { Err(ApiError::BadRequest(format!( "release_type must be one of: {}", RELEASE_TYPES.join(", ") ))) } } #[derive(Debug, Deserialize)] struct ListParams { /// Substring match against title (normalized) q: Option, artist_id: Option, release_type: Option, year: Option, hidden: Option, limit: Option, offset: Option, } async fn list( State(pool): State, Query(params): Query, ) -> ApiResult>> { let mut qb = sqlx::QueryBuilder::new("SELECT r.* FROM releases r WHERE 1=1"); if let Some(artist_id) = params.artist_id { qb.push( " AND EXISTS (SELECT 1 FROM release_artists ra WHERE ra.release_id = r.id AND ra.artist_id = ", ) .push_bind(artist_id) .push(")"); } if let Some(q) = ¶ms.q { qb.push(" AND r.title_sort LIKE ") .push_bind(format!("%{}%", normalize_name(q))); } if let Some(release_type) = ¶ms.release_type { qb.push(" AND r.release_type = ").push_bind(release_type); } if let Some(year) = params.year { qb.push(" AND r.year = ").push_bind(year); } if let Some(hidden) = params.hidden { qb.push(" AND r.is_hidden = ").push_bind(hidden); } qb.push(" ORDER BY r.title_sort LIMIT ") .push_bind(page_limit(params.limit)) .push(" OFFSET ") .push_bind(page_offset(params.offset)); let rows = qb.build_query_as::().fetch_all(&pool).await?; Ok(Json(rows)) } #[derive(Debug, Serialize, sqlx::FromRow)] struct ReleaseArtistOut { id: i64, name: String, position: i64, } #[derive(Debug, Serialize, sqlx::FromRow)] struct ReleaseTrackOut { id: i64, title: String, track_number: Option, disc_number: Option, duration_seconds: f64, audio_file_id: i64, } #[derive(Debug, Serialize)] struct ReleaseDetail { #[serde(flatten)] release: Release, artists: Vec, tracks: Vec, } async fn get_one( State(pool): State, Path(id): Path, ) -> ApiResult> { let release = fetch(&pool, id).await?; let artists = sqlx::query_as::<_, ReleaseArtistOut>( "SELECT a.id, a.name, ra.position FROM artists a JOIN release_artists ra ON ra.artist_id = a.id WHERE ra.release_id = ? ORDER BY ra.position", ) .bind(id) .fetch_all(&pool) .await?; let tracks = sqlx::query_as::<_, ReleaseTrackOut>( "SELECT id, title, track_number, disc_number, duration_seconds, audio_file_id FROM tracks WHERE release_id = ? ORDER BY disc_number, track_number, title_sort", ) .bind(id) .fetch_all(&pool) .await?; Ok(Json(ReleaseDetail { release, artists, tracks, })) } #[derive(Debug, Deserialize)] struct CreateRelease { /// Explicit id for migration; omit for auto-increment. id: Option, title: String, #[serde(default)] release_type: Option, year: Option, cover_file_id: Option, total_tracks: Option, total_discs: Option, #[serde(default)] is_hidden: bool, model_name: Option, /// Ordered list of artist ids; positions are assigned by index. #[serde(default)] artist_ids: Vec, /// Overrides for migration; default to now. created_at: Option, updated_at: Option, } async fn replace_artists( tx: &mut Transaction<'_, Sqlite>, release_id: i64, artist_ids: &[i64], ) -> ApiResult<()> { sqlx::query("DELETE FROM release_artists WHERE release_id = ?") .bind(release_id) .execute(&mut **tx) .await?; for (position, artist_id) in artist_ids.iter().enumerate() { sqlx::query( "INSERT INTO release_artists (release_id, artist_id, position) VALUES (?, ?, ?)", ) .bind(release_id) .bind(artist_id) .bind(position as i64) .execute(&mut **tx) .await?; } Ok(()) } async fn create( State(pool): State, Json(body): Json, ) -> ApiResult> { if body.title.trim().is_empty() { return Err(ApiError::BadRequest("title must not be empty".to_string())); } let release_type = body.release_type.unwrap_or_else(|| "album".to_string()); validate_release_type(&release_type)?; let now = now_iso(); let mut tx = pool.begin().await?; let row = sqlx::query_as::<_, Release>( "INSERT INTO releases ( id, title, title_sort, release_type, year, cover_file_id, total_tracks, total_discs, is_hidden, model_name, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *", ) .bind(body.id) .bind(&body.title) .bind(normalize_name(&body.title)) .bind(&release_type) .bind(body.year) .bind(body.cover_file_id) .bind(body.total_tracks) .bind(body.total_discs) .bind(body.is_hidden) .bind(&body.model_name) .bind(body.created_at.unwrap_or_else(|| now.clone())) .bind(body.updated_at.unwrap_or(now)) .fetch_one(&mut *tx) .await?; replace_artists(&mut tx, row.id, &body.artist_ids).await?; tx.commit().await?; Ok(Json(row)) } #[derive(Debug, Deserialize)] struct UpdateRelease { title: Option, release_type: Option, #[serde(default, deserialize_with = "double_option")] year: Option>, #[serde(default, deserialize_with = "double_option")] cover_file_id: Option>, #[serde(default, deserialize_with = "double_option")] total_tracks: Option>, #[serde(default, deserialize_with = "double_option")] total_discs: Option>, is_hidden: Option, #[serde(default, deserialize_with = "double_option")] model_name: Option>, } async fn update( State(pool): State, Path(id): Path, Json(body): Json, ) -> ApiResult> { let mut row = fetch(&pool, id).await?; if let Some(title) = body.title { if title.trim().is_empty() { return Err(ApiError::BadRequest("title must not be empty".to_string())); } row.title_sort = normalize_name(&title); row.title = title; } if let Some(release_type) = body.release_type { validate_release_type(&release_type)?; row.release_type = release_type; } if let Some(v) = body.year { row.year = v; } if let Some(v) = body.cover_file_id { row.cover_file_id = v; } if let Some(v) = body.total_tracks { row.total_tracks = v; } if let Some(v) = body.total_discs { row.total_discs = v; } if let Some(v) = body.is_hidden { row.is_hidden = v; } if let Some(v) = body.model_name { row.model_name = v; } row.updated_at = now_iso(); sqlx::query( "UPDATE releases SET title = ?, title_sort = ?, release_type = ?, year = ?, cover_file_id = ?, total_tracks = ?, total_discs = ?, is_hidden = ?, model_name = ?, updated_at = ? WHERE id = ?", ) .bind(&row.title) .bind(&row.title_sort) .bind(&row.release_type) .bind(row.year) .bind(row.cover_file_id) .bind(row.total_tracks) .bind(row.total_discs) .bind(row.is_hidden) .bind(&row.model_name) .bind(&row.updated_at) .bind(id) .execute(&pool) .await?; Ok(Json(row)) } #[derive(Debug, Deserialize)] struct SetArtists { /// Ordered list of artist ids; positions are assigned by index. artist_ids: Vec, } async fn set_artists( State(pool): State, Path(id): Path, Json(body): Json, ) -> ApiResult> { fetch(&pool, id).await?; let mut tx = pool.begin().await?; replace_artists(&mut tx, id, &body.artist_ids).await?; sqlx::query("UPDATE releases SET updated_at = ? WHERE id = ?") .bind(now_iso()) .bind(id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json( serde_json::json!({ "release_id": id, "artist_ids": body.artist_ids }), )) } async fn delete_one( State(pool): State, Path(id): Path, ) -> ApiResult> { let mut tx = pool.begin().await?; let track_ids: Vec<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE release_id = ?") .bind(id) .fetch_all(&mut *tx) .await?; let result = sqlx::query("DELETE FROM releases WHERE id = ?") .bind(id) .execute(&mut *tx) .await?; if result.rows_affected() == 0 { return Err(ApiError::not_found("release", id)); } // Tracks and link rows cascade via FK; polymorphic metadata is cleaned manually. sqlx::query("DELETE FROM entity_genre_tags WHERE entity_kind = 'release' AND entity_id = ?") .bind(id) .execute(&mut *tx) .await?; sqlx::query( "DELETE FROM external_metadata_ids WHERE entity_kind = 'release' AND entity_id = ?", ) .bind(id) .execute(&mut *tx) .await?; for (track_id,) in &track_ids { sqlx::query("DELETE FROM entity_genre_tags WHERE entity_kind = 'track' AND entity_id = ?") .bind(track_id) .execute(&mut *tx) .await?; sqlx::query( "DELETE FROM external_metadata_ids WHERE entity_kind = 'track' AND entity_id = ?", ) .bind(track_id) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(Json(serde_json::json!({ "deleted": id, "deleted_tracks": track_ids.iter().map(|(t,)| *t).collect::>(), }))) }