init
This commit is contained in:
@@ -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) = ¶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 })))
|
||||
}
|
||||
Reference in New Issue
Block a user