//! Polymorphic metadata: weighted genre tags and external identifiers //! attached to an artist, release or track. use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use crate::error::{ApiError, ApiResult}; use crate::util::{ENTITY_KINDS, now_iso, page_limit, page_offset}; pub fn genre_tags_router() -> Router { Router::new() .route("/", get(list_genre_tags).post(upsert_genre_tag)) .route("/{id}", axum::routing::delete(delete_genre_tag)) } pub fn external_ids_router() -> Router { Router::new() .route("/", get(list_external_ids).post(upsert_external_id)) .route("/{id}", axum::routing::delete(delete_external_id)) } async fn validate_entity(pool: &SqlitePool, entity_kind: &str, entity_id: i64) -> ApiResult<()> { let table = match entity_kind { "artist" => "artists", "release" => "releases", "track" => "tracks", _ => { return Err(ApiError::BadRequest(format!( "entity_kind must be one of: {}", ENTITY_KINDS.join(", ") ))); } }; // The table name comes from the whitelist above, never from user input. let exists: Option<(i64,)> = sqlx::query_as(sqlx::AssertSqlSafe(format!( "SELECT id FROM {table} WHERE id = ?" ))) .bind(entity_id) .fetch_optional(pool) .await?; if exists.is_none() { return Err(ApiError::not_found(entity_kind, entity_id)); } Ok(()) } // --------------------------------------------------------------------------- // Genre tags // --------------------------------------------------------------------------- #[derive(Debug, Serialize, sqlx::FromRow)] pub struct GenreTag { pub id: i64, pub entity_kind: String, pub entity_id: i64, pub genre_id: i64, pub genre_name: String, pub source: String, pub weight: f64, pub updated_at: String, } #[derive(Debug, Deserialize)] struct GenreTagListParams { entity_kind: Option, entity_id: Option, genre_id: Option, source: Option, limit: Option, offset: Option, } async fn list_genre_tags( State(pool): State, Query(params): Query, ) -> ApiResult>> { let mut qb = sqlx::QueryBuilder::new( "SELECT egt.id, egt.entity_kind, egt.entity_id, egt.genre_id, g.name AS genre_name, egt.source, egt.weight, egt.updated_at FROM entity_genre_tags egt JOIN genres g ON g.id = egt.genre_id WHERE 1=1", ); if let Some(entity_kind) = ¶ms.entity_kind { qb.push(" AND egt.entity_kind = ").push_bind(entity_kind); } if let Some(entity_id) = params.entity_id { qb.push(" AND egt.entity_id = ").push_bind(entity_id); } if let Some(genre_id) = params.genre_id { qb.push(" AND egt.genre_id = ").push_bind(genre_id); } if let Some(source) = ¶ms.source { qb.push(" AND egt.source = ").push_bind(source); } qb.push(" ORDER BY egt.weight DESC, egt.id 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, Deserialize)] struct UpsertGenreTag { entity_kind: String, entity_id: i64, genre_id: i64, source: String, #[serde(default)] weight: Option, } /// Upsert on (entity_kind, entity_id, genre_id, source): posting an existing /// combination updates its weight. async fn upsert_genre_tag( State(pool): State, Json(body): Json, ) -> ApiResult> { validate_entity(&pool, &body.entity_kind, body.entity_id).await?; let (id,): (i64,) = sqlx::query_as( "INSERT INTO entity_genre_tags (entity_kind, entity_id, genre_id, source, weight, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (entity_kind, entity_id, genre_id, source) DO UPDATE SET weight = excluded.weight, updated_at = excluded.updated_at RETURNING id", ) .bind(&body.entity_kind) .bind(body.entity_id) .bind(body.genre_id) .bind(&body.source) .bind(body.weight.unwrap_or(1.0)) .bind(now_iso()) .fetch_one(&pool) .await?; let row = sqlx::query_as::<_, GenreTag>( "SELECT egt.id, egt.entity_kind, egt.entity_id, egt.genre_id, g.name AS genre_name, egt.source, egt.weight, egt.updated_at FROM entity_genre_tags egt JOIN genres g ON g.id = egt.genre_id WHERE egt.id = ?", ) .bind(id) .fetch_one(&pool) .await?; Ok(Json(row)) } async fn delete_genre_tag( State(pool): State, Path(id): Path, ) -> ApiResult> { let result = sqlx::query("DELETE FROM entity_genre_tags WHERE id = ?") .bind(id) .execute(&pool) .await?; if result.rows_affected() == 0 { return Err(ApiError::not_found("genre tag", id)); } Ok(Json(serde_json::json!({ "deleted": id }))) } // --------------------------------------------------------------------------- // External metadata ids (MusicBrainz, Last.fm, Discogs, ...) // --------------------------------------------------------------------------- #[derive(Debug, Serialize, sqlx::FromRow)] pub struct ExternalId { pub id: i64, pub entity_kind: String, pub entity_id: i64, pub source: String, pub id_kind: String, pub external_id: String, pub confidence: f64, pub updated_at: String, } #[derive(Debug, Deserialize)] struct ExternalIdListParams { entity_kind: Option, entity_id: Option, source: Option, id_kind: Option, external_id: Option, limit: Option, offset: Option, } async fn list_external_ids( State(pool): State, Query(params): Query, ) -> ApiResult>> { let mut qb = sqlx::QueryBuilder::new("SELECT * FROM external_metadata_ids WHERE 1=1"); if let Some(entity_kind) = ¶ms.entity_kind { qb.push(" AND entity_kind = ").push_bind(entity_kind); } if let Some(entity_id) = params.entity_id { qb.push(" AND entity_id = ").push_bind(entity_id); } if let Some(source) = ¶ms.source { qb.push(" AND source = ").push_bind(source); } if let Some(id_kind) = ¶ms.id_kind { qb.push(" AND id_kind = ").push_bind(id_kind); } if let Some(external_id) = ¶ms.external_id { qb.push(" AND external_id = ").push_bind(external_id); } qb.push(" ORDER BY id 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, Deserialize)] struct UpsertExternalId { entity_kind: String, entity_id: i64, source: String, id_kind: String, external_id: String, #[serde(default)] confidence: Option, } /// Upsert on (entity_kind, entity_id, source, id_kind): posting an existing /// combination updates external_id and confidence. async fn upsert_external_id( State(pool): State, Json(body): Json, ) -> ApiResult> { validate_entity(&pool, &body.entity_kind, body.entity_id).await?; let row = sqlx::query_as::<_, ExternalId>( "INSERT INTO external_metadata_ids (entity_kind, entity_id, source, id_kind, external_id, confidence, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (entity_kind, entity_id, source, id_kind) DO UPDATE SET external_id = excluded.external_id, confidence = excluded.confidence, updated_at = excluded.updated_at RETURNING *", ) .bind(&body.entity_kind) .bind(body.entity_id) .bind(&body.source) .bind(&body.id_kind) .bind(&body.external_id) .bind(body.confidence.unwrap_or(1.0)) .bind(now_iso()) .fetch_one(&pool) .await?; Ok(Json(row)) } async fn delete_external_id( State(pool): State, Path(id): Path, ) -> ApiResult> { let result = sqlx::query("DELETE FROM external_metadata_ids WHERE id = ?") .bind(id) .execute(&pool) .await?; if result.rows_affected() == 0 { return Err(ApiError::not_found("external id", id)); } Ok(Json(serde_json::json!({ "deleted": id }))) }