init
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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<SqlitePool> {
|
||||
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<i64>,
|
||||
pub cover_file_id: Option<i64>,
|
||||
pub total_tracks: Option<i64>,
|
||||
pub total_discs: Option<i64>,
|
||||
pub is_hidden: bool,
|
||||
pub model_name: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn fetch(pool: &SqlitePool, id: i64) -> ApiResult<Release> {
|
||||
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<String>,
|
||||
artist_id: Option<i64>,
|
||||
release_type: Option<String>,
|
||||
year: Option<i64>,
|
||||
hidden: Option<bool>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list(
|
||||
State(pool): State<SqlitePool>,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> ApiResult<Json<Vec<Release>>> {
|
||||
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::<Release>().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<i64>,
|
||||
disc_number: Option<i64>,
|
||||
duration_seconds: f64,
|
||||
audio_file_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReleaseDetail {
|
||||
#[serde(flatten)]
|
||||
release: Release,
|
||||
artists: Vec<ReleaseArtistOut>,
|
||||
tracks: Vec<ReleaseTrackOut>,
|
||||
}
|
||||
|
||||
async fn get_one(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<Json<ReleaseDetail>> {
|
||||
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<i64>,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
release_type: Option<String>,
|
||||
year: Option<i64>,
|
||||
cover_file_id: Option<i64>,
|
||||
total_tracks: Option<i64>,
|
||||
total_discs: Option<i64>,
|
||||
#[serde(default)]
|
||||
is_hidden: bool,
|
||||
model_name: Option<String>,
|
||||
/// Ordered list of artist ids; positions are assigned by index.
|
||||
#[serde(default)]
|
||||
artist_ids: Vec<i64>,
|
||||
/// Overrides for migration; default to now.
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
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<SqlitePool>,
|
||||
Json(body): Json<CreateRelease>,
|
||||
) -> ApiResult<Json<Release>> {
|
||||
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<String>,
|
||||
release_type: Option<String>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
year: Option<Option<i64>>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
cover_file_id: Option<Option<i64>>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
total_tracks: Option<Option<i64>>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
total_discs: Option<Option<i64>>,
|
||||
is_hidden: Option<bool>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
model_name: Option<Option<String>>,
|
||||
}
|
||||
|
||||
async fn update(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<UpdateRelease>,
|
||||
) -> ApiResult<Json<Release>> {
|
||||
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<i64>,
|
||||
}
|
||||
|
||||
async fn set_artists(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<SetArtists>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
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<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
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::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user