This commit is contained in:
Ultradesu
2026-07-16 17:22:32 +03:00
commit d77a985708
17 changed files with 8145 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
//! 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<SqlitePool> {
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<SqlitePool> {
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<String>,
entity_id: Option<i64>,
genre_id: Option<i64>,
source: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list_genre_tags(
State(pool): State<SqlitePool>,
Query(params): Query<GenreTagListParams>,
) -> ApiResult<Json<Vec<GenreTag>>> {
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) = &params.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) = &params.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::<GenreTag>().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<f64>,
}
/// Upsert on (entity_kind, entity_id, genre_id, source): posting an existing
/// combination updates its weight.
async fn upsert_genre_tag(
State(pool): State<SqlitePool>,
Json(body): Json<UpsertGenreTag>,
) -> ApiResult<Json<GenreTag>> {
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<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<serde_json::Value>> {
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<String>,
entity_id: Option<i64>,
source: Option<String>,
id_kind: Option<String>,
external_id: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list_external_ids(
State(pool): State<SqlitePool>,
Query(params): Query<ExternalIdListParams>,
) -> ApiResult<Json<Vec<ExternalId>>> {
let mut qb = sqlx::QueryBuilder::new("SELECT * FROM external_metadata_ids WHERE 1=1");
if let Some(entity_kind) = &params.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) = &params.source {
qb.push(" AND source = ").push_bind(source);
}
if let Some(id_kind) = &params.id_kind {
qb.push(" AND id_kind = ").push_bind(id_kind);
}
if let Some(external_id) = &params.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::<ExternalId>().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<f64>,
}
/// 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<SqlitePool>,
Json(body): Json<UpsertExternalId>,
) -> ApiResult<Json<ExternalId>> {
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<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<serde_json::Value>> {
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 })))
}