379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
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<_>>(),
|
|
})))
|
|
}
|