274 lines
8.6 KiB
Rust
274 lines
8.6 KiB
Rust
//! 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) = ¶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::<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) = ¶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::<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 })))
|
||
|
|
}
|