Files
furumi-fd/src/routes/releases.rs
T
2026-07-16 17:22:32 +03:00

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) = &params.q {
qb.push(" AND r.title_sort LIKE ")
.push_bind(format!("%{}%", normalize_name(q)));
}
if let Some(release_type) = &params.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<_>>(),
})))
}