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
+62
View File
@@ -0,0 +1,62 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug)]
pub enum ApiError {
NotFound(String),
BadRequest(String),
Conflict(String),
Internal(anyhow::Error),
}
impl ApiError {
pub fn not_found(what: &str, id: i64) -> Self {
Self::NotFound(format!("{what} {id} not found"))
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
Self::Conflict(msg) => (StatusCode::CONFLICT, msg),
Self::Internal(err) => {
tracing::error!("internal error: {err:#}");
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
if let sqlx::Error::Database(db_err) = &err {
let msg = db_err.message().to_string();
if msg.contains("FOREIGN KEY constraint failed") {
return Self::Conflict(
"operation violates a foreign key constraint (referenced row missing or still in use)"
.to_string(),
);
}
if msg.contains("UNIQUE constraint failed") {
return Self::Conflict(msg);
}
if msg.contains("CHECK constraint failed") {
return Self::BadRequest(msg);
}
}
Self::Internal(err.into())
}
}
impl From<anyhow::Error> for ApiError {
fn from(err: anyhow::Error) -> Self {
Self::Internal(err)
}
}
+499
View File
@@ -0,0 +1,499 @@
//! P2P federation: publishes the local library index (artists, releases,
//! tracks — names and small metadata, never files) into a distributed hash
//! table shared by every furumi-fd instance that joined the same network,
//! and searches the other participants' libraries.
//!
//! Built on the `music-dht` crate. Peers of a network find each other
//! automatically knowing only the network name (rendezvous through the
//! BitTorrent Mainline DHT); no tickets or bootstrap servers are involved.
//!
//! Federation is **off by default** and controlled at runtime through
//! `/api/federation`; the settings persist in the `federation_settings`
//! table.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Query, State};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use music_dht::{
ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, RendezvousConfig,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::{Row, SqlitePool};
use tokio::task::JoinHandle;
use crate::error::{ApiError, ApiResult};
use crate::util::now_iso;
/// How often the published library is re-synchronized with the local index.
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
/// Persisted federation settings (one row in `federation_settings`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationSettings {
pub enabled: bool,
pub network_id: String,
}
/// Outcome of the most recent library synchronization.
#[derive(Debug, Clone, Serialize)]
struct LastSync {
at: String,
added: usize,
updated: usize,
removed: usize,
unchanged: usize,
failed: usize,
}
/// State of a running DHT node.
struct Running {
service: Arc<MusicDhtService>,
network_name: String,
tasks: Vec<JoinHandle<()>>,
}
/// The federation subsystem: settings, node lifecycle and search.
pub struct Federation {
pool: SqlitePool,
data_dir: PathBuf,
running: tokio::sync::Mutex<Option<Running>>,
last_sync: std::sync::Mutex<Option<LastSync>>,
last_error: std::sync::Mutex<Option<String>>,
}
impl Federation {
pub fn new(pool: SqlitePool, data_dir: PathBuf) -> Arc<Self> {
Arc::new(Self {
pool,
data_dir,
running: tokio::sync::Mutex::new(None),
last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(None),
})
}
async fn load_settings(&self) -> ApiResult<FederationSettings> {
let row = sqlx::query("SELECT enabled, network_id FROM federation_settings WHERE id = 1")
.fetch_one(&self.pool)
.await?;
Ok(FederationSettings {
enabled: row.get::<i64, _>(0) != 0,
network_id: row.get(1),
})
}
async fn save_settings(&self, settings: &FederationSettings) -> ApiResult<()> {
sqlx::query("UPDATE federation_settings SET enabled = ?, network_id = ? WHERE id = 1")
.bind(settings.enabled as i64)
.bind(&settings.network_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Starts the DHT node on boot when federation was left enabled.
pub async fn start_if_enabled(self: &Arc<Self>) {
match self.load_settings().await {
Ok(settings) if settings.enabled && !settings.network_id.trim().is_empty() => {
if let Err(err) = self.start(settings.network_id).await {
tracing::error!("federation autostart failed: {err:?}");
self.set_error(format!("autostart failed: {err}"));
}
}
Ok(_) => {}
Err(err) => tracing::error!("failed to load federation settings: {err:?}"),
}
}
fn set_error(&self, message: impl Into<Option<String>>) {
*self
.last_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = message.into();
}
/// Starts the DHT node for `network_name`. Idempotent per network: a node
/// already running on the same network is kept; a node on a different
/// network is stopped first.
async fn start(self: &Arc<Self>, network_name: String) -> anyhow::Result<()> {
let mut guard = self.running.lock().await;
if let Some(running) = guard.as_ref() {
if running.network_name == network_name {
return Ok(());
}
stop_running(guard.take()).await;
}
let config = MusicDhtConfig::builder()
.data_dir(&self.data_dir)
.network_id(NetworkId::from_name(&network_name))
// Peers of the network find each other knowing only its name.
.rendezvous(RendezvousConfig::default())
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
let (service, mut events) = MusicDhtService::start(config)
.await
.map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?;
let service = Arc::new(service);
tracing::info!(
endpoint_id = %service.endpoint_id(),
network = %network_name,
"federation started"
);
// Drain DHT events into the log; the channel is bounded and must be
// consumed.
let event_task = tokio::spawn(async move {
while let Some(event) = events.recv().await {
tracing::debug!("federation event: {event:?}");
}
});
// Keep the published library in sync with the local index.
let sync_self = self.clone();
let sync_service = service.clone();
let sync_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(SYNC_INTERVAL);
loop {
interval.tick().await;
sync_self.sync_once(&sync_service).await;
}
});
*guard = Some(Running {
service,
network_name,
tasks: vec![event_task, sync_task],
});
self.set_error(None);
Ok(())
}
/// Stops the DHT node if it is running.
async fn stop(&self) {
let mut guard = self.running.lock().await;
stop_running(guard.take()).await;
}
/// Called on process shutdown.
pub async fn shutdown(&self) {
self.stop().await;
}
/// Runs one library synchronization and records the outcome.
async fn sync_once(&self, service: &MusicDhtService) {
let specs = match collect_library(&self.pool).await {
Ok(specs) => specs,
Err(err) => {
tracing::warn!("federation sync: failed to read the library: {err:?}");
self.set_error(format!("library read failed: {err}"));
return;
}
};
match service.sync_library(specs).await {
Ok(stats) => {
*self
.last_sync
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(LastSync {
at: now_iso(),
added: stats.added,
updated: stats.updated,
removed: stats.removed,
unchanged: stats.unchanged,
failed: stats.failed,
});
self.set_error(None);
}
Err(err) => {
tracing::warn!("federation sync failed: {err}");
self.set_error(format!("sync failed: {err}"));
}
}
}
/// Current status: settings plus live node statistics.
async fn status(&self) -> ApiResult<Value> {
let settings = self.load_settings().await?;
let guard = self.running.lock().await;
let node = match guard.as_ref() {
Some(running) => {
let service = &running.service;
let published = service
.list_local_items()
.await
.map(|items| items.len())
.unwrap_or(0);
let peers: Vec<String> = service
.connected_peers()
.iter()
.map(|p| p.to_string())
.collect();
json!({
"running": true,
"network": running.network_name,
"endpoint_id": service.endpoint_id().to_string(),
"node_id": service.node_id().to_string(),
"connected_peers": peers,
"known_contacts": service.known_peers().len(),
"published_items": published,
})
}
None => json!({ "running": false }),
};
let last_sync = self
.last_sync
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let last_error = self
.last_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(json!({
"settings": settings,
"node": node,
"last_sync": last_sync,
"last_error": last_error,
}))
}
}
async fn stop_running(running: Option<Running>) {
let Some(running) = running else { return };
for task in &running.tasks {
task.abort();
}
if let Err(err) = running.service.shutdown().await {
tracing::warn!("federation node shutdown reported an error: {err}");
}
tracing::info!("federation stopped");
}
/// Reads the local library index and converts it into DHT item specs.
///
/// Only names and small metadata are shared — never file paths, hashes or
/// anything about the files themselves.
async fn collect_library(pool: &SqlitePool) -> anyhow::Result<Vec<ItemSpec>> {
let mut specs = Vec::new();
let artists = sqlx::query("SELECT id, name FROM artists WHERE is_hidden = 0")
.fetch_all(pool)
.await?;
for row in &artists {
let id: i64 = row.get(0);
specs.push(ItemSpec {
local_key: format!("artist:{id}"),
kind: ItemKind::Artist,
name: row.get(1),
artist_names: Vec::new(),
year: None,
release_type: None,
duration_seconds: None,
});
}
let release_artists = sqlx::query(
"SELECT ra.release_id, a.name FROM release_artists ra
JOIN artists a ON a.id = ra.artist_id
ORDER BY ra.release_id, ra.position",
)
.fetch_all(pool)
.await?;
let mut artists_of_release: std::collections::HashMap<i64, Vec<String>> = Default::default();
for row in &release_artists {
artists_of_release
.entry(row.get(0))
.or_default()
.push(row.get(1));
}
let releases =
sqlx::query("SELECT id, title, year, release_type FROM releases WHERE is_hidden = 0")
.fetch_all(pool)
.await?;
for row in &releases {
let id: i64 = row.get(0);
specs.push(ItemSpec {
local_key: format!("release:{id}"),
kind: ItemKind::Release,
name: row.get(1),
artist_names: artists_of_release.remove(&id).unwrap_or_default(),
year: row.get(2),
release_type: row.get(3),
duration_seconds: None,
});
}
let track_artists = sqlx::query(
"SELECT ta.track_id, a.name FROM track_artists ta
JOIN artists a ON a.id = ta.artist_id
ORDER BY ta.track_id, ta.position",
)
.fetch_all(pool)
.await?;
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
for row in &track_artists {
artists_of_track
.entry(row.get(0))
.or_default()
.push(row.get(1));
}
let tracks =
sqlx::query("SELECT id, title, year, duration_seconds FROM tracks WHERE is_hidden = 0")
.fetch_all(pool)
.await?;
for row in &tracks {
let id: i64 = row.get(0);
let duration: f64 = row.get(3);
specs.push(ItemSpec {
local_key: format!("track:{id}"),
kind: ItemKind::Track,
name: row.get(1),
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
year: row.get(2),
release_type: None,
duration_seconds: (duration > 0.0).then_some(duration),
});
}
Ok(specs)
}
fn item_to_json(item: &LibraryItem) -> Value {
json!({
"kind": item.kind.as_str(),
"name": item.name,
"artist_names": item.artist_names,
"year": item.year,
"release_type": item.release_type,
"duration_seconds": item.duration_seconds,
"owner": item.owner.to_string(),
"updated_at_ms": item.updated_at_ms,
})
}
// ---------------------------------------------------------------------------
// HTTP API
// ---------------------------------------------------------------------------
pub fn router() -> Router<Arc<Federation>> {
Router::new()
.route("/federation", get(get_status))
.route("/federation/settings", put(put_settings))
.route("/federation/search", get(search))
.route("/federation/sync", post(sync_now))
}
async fn get_status(State(fed): State<Arc<Federation>>) -> ApiResult<Json<Value>> {
Ok(Json(fed.status().await?))
}
#[derive(Debug, Deserialize)]
struct PutSettings {
enabled: bool,
network_id: String,
}
/// Applies new settings: persists them and starts/stops/restarts the node.
async fn put_settings(
State(fed): State<Arc<Federation>>,
Json(body): Json<PutSettings>,
) -> ApiResult<Json<Value>> {
let network_id = body.network_id.trim().to_string();
if body.enabled && network_id.is_empty() {
return Err(ApiError::BadRequest(
"network_id must not be empty when federation is enabled".to_string(),
));
}
let settings = FederationSettings {
enabled: body.enabled,
network_id,
};
fed.save_settings(&settings).await?;
if settings.enabled {
fed.start(settings.network_id.clone())
.await
.map_err(|err| ApiError::BadRequest(format!("failed to start federation: {err}")))?;
// Publish the library right away instead of waiting for the timer.
let guard = fed.running.lock().await;
if let Some(running) = guard.as_ref() {
let fed = fed.clone();
let service = running.service.clone();
tokio::spawn(async move { fed.sync_once(&service).await });
}
} else {
fed.stop().await;
}
Ok(Json(fed.status().await?))
}
#[derive(Debug, Deserialize)]
struct SearchParams {
q: String,
/// Optional filter: artist | release | track.
kind: Option<String>,
}
/// Searches the federated network (and the local replica store).
async fn search(
State(fed): State<Arc<Federation>>,
Query(params): Query<SearchParams>,
) -> ApiResult<Json<Value>> {
let kind_filter: Option<ItemKind> = match params.kind.as_deref() {
None | Some("") => None,
Some(kind) => Some(
kind.parse()
.map_err(|_| ApiError::BadRequest(format!("unknown kind '{kind}'")))?,
),
};
let guard = fed.running.lock().await;
let Some(running) = guard.as_ref() else {
return Err(ApiError::BadRequest(
"federation is not running".to_string(),
));
};
let service = running.service.clone();
drop(guard);
let outcome = service
.search_network(&params.q)
.await
.map_err(|err| ApiError::BadRequest(format!("search failed: {err}")))?;
let own = service.endpoint_id();
let results: Vec<Value> = outcome
.network_results
.iter()
.filter(|item| kind_filter.is_none_or(|kind| item.kind == kind))
.map(|item| {
let mut value = item_to_json(item);
value["own"] = Value::Bool(item.owner == own);
value
})
.collect();
Ok(Json(json!({
"query": params.q,
"results": results,
"queried_nodes": outcome.queried_nodes,
"discovered_nodes": outcome.discovered_nodes,
"duration_ms": outcome.duration.as_millis() as u64,
})))
}
/// Forces an immediate library synchronization.
async fn sync_now(State(fed): State<Arc<Federation>>) -> ApiResult<Json<Value>> {
let guard = fed.running.lock().await;
let Some(running) = guard.as_ref() else {
return Err(ApiError::BadRequest(
"federation is not running".to_string(),
));
};
let service = running.service.clone();
drop(guard);
fed.sync_once(&service).await;
Ok(Json(fed.status().await?))
}
+66
View File
@@ -0,0 +1,66 @@
mod error;
mod federation;
mod routes;
mod schema;
mod util;
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "furumi_fd=info,tower_http=info".into()),
)
.init();
let db_path = std::env::var("FURUMI_FD_DB").unwrap_or_else(|_| "furumi-fd.sqlite3".to_string());
let listen_addr =
std::env::var("FURUMI_FD_LISTEN").unwrap_or_else(|_| "127.0.0.1:8321".to_string());
let options = SqliteConnectOptions::from_str(&format!("sqlite://{db_path}"))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(options)
.await?;
schema::init(&pool).await?;
tracing::info!("database ready at {db_path}");
// Federation state (identity, DHT replicas) lives outside the main db.
let federation_dir = std::env::var("FURUMI_FD_FEDERATION_DIR")
.unwrap_or_else(|_| format!("{db_path}.federation"));
let federation = federation::Federation::new(pool.clone(), federation_dir.into());
federation.start_if_enabled().await;
let app = axum::Router::new()
.route(
"/",
axum::routing::get(|| async {
axum::response::Html(include_str!("../static/index.html"))
}),
)
.nest(
"/api",
routes::api_router()
.with_state(pool)
.merge(federation::router().with_state(federation.clone())),
);
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
tracing::info!("furumi-fd listening on http://{listen_addr}");
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
// Leave the DHT gracefully so peers see a clean disconnect.
federation.shutdown().await;
Ok(())
}
+222
View File
@@ -0,0 +1,222 @@
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::{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))
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Artist {
pub id: i64,
pub name: String,
pub name_sort: String,
pub image_file_id: 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<Artist> {
sqlx::query_as::<_, Artist>("SELECT * FROM artists WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::not_found("artist", id))
}
#[derive(Debug, Deserialize)]
struct ListParams {
/// Substring match against name (normalized)
q: Option<String>,
hidden: Option<bool>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list(
State(pool): State<SqlitePool>,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Vec<Artist>>> {
let mut qb = sqlx::QueryBuilder::new("SELECT * FROM artists WHERE 1=1");
if let Some(q) = &params.q {
qb.push(" AND name_sort LIKE ")
.push_bind(format!("%{}%", normalize_name(q)));
}
if let Some(hidden) = params.hidden {
qb.push(" AND is_hidden = ").push_bind(hidden);
}
qb.push(" ORDER BY name_sort LIMIT ")
.push_bind(page_limit(params.limit))
.push(" OFFSET ")
.push_bind(page_offset(params.offset));
let rows = qb.build_query_as::<Artist>().fetch_all(&pool).await?;
Ok(Json(rows))
}
#[derive(Debug, Serialize, sqlx::FromRow)]
struct ArtistRelease {
id: i64,
title: String,
release_type: String,
year: Option<i64>,
position: i64,
}
#[derive(Debug, Serialize)]
struct ArtistDetail {
#[serde(flatten)]
artist: Artist,
releases: Vec<ArtistRelease>,
track_count: i64,
}
async fn get_one(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<ArtistDetail>> {
let artist = fetch(&pool, id).await?;
let releases = sqlx::query_as::<_, ArtistRelease>(
"SELECT r.id, r.title, r.release_type, r.year, ra.position
FROM releases r
JOIN release_artists ra ON ra.release_id = r.id
WHERE ra.artist_id = ?
ORDER BY r.year, r.title_sort",
)
.bind(id)
.fetch_all(&pool)
.await?;
let (track_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM track_artists WHERE artist_id = ?")
.bind(id)
.fetch_one(&pool)
.await?;
Ok(Json(ArtistDetail {
artist,
releases,
track_count,
}))
}
#[derive(Debug, Deserialize)]
struct CreateArtist {
/// Explicit id for migration; omit for auto-increment.
id: Option<i64>,
name: String,
image_file_id: Option<i64>,
#[serde(default)]
is_hidden: bool,
model_name: Option<String>,
/// Overrides for migration; default to now.
created_at: Option<String>,
updated_at: Option<String>,
}
async fn create(
State(pool): State<SqlitePool>,
Json(body): Json<CreateArtist>,
) -> ApiResult<Json<Artist>> {
if body.name.trim().is_empty() {
return Err(ApiError::BadRequest("name must not be empty".to_string()));
}
let now = now_iso();
let row = sqlx::query_as::<_, Artist>(
"INSERT INTO artists (id, name, name_sort, image_file_id, is_hidden, model_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *",
)
.bind(body.id)
.bind(&body.name)
.bind(normalize_name(&body.name))
.bind(body.image_file_id)
.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(&pool)
.await?;
Ok(Json(row))
}
#[derive(Debug, Deserialize)]
struct UpdateArtist {
name: Option<String>,
#[serde(default, deserialize_with = "double_option")]
image_file_id: 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<UpdateArtist>,
) -> ApiResult<Json<Artist>> {
let mut row = fetch(&pool, id).await?;
if let Some(name) = body.name {
if name.trim().is_empty() {
return Err(ApiError::BadRequest("name must not be empty".to_string()));
}
row.name_sort = normalize_name(&name);
row.name = name;
}
if let Some(v) = body.image_file_id {
row.image_file_id = 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 artists SET name = ?, name_sort = ?, image_file_id = ?, is_hidden = ?,
model_name = ?, updated_at = ?
WHERE id = ?",
)
.bind(&row.name)
.bind(&row.name_sort)
.bind(row.image_file_id)
.bind(row.is_hidden)
.bind(&row.model_name)
.bind(&row.updated_at)
.bind(id)
.execute(&pool)
.await?;
Ok(Json(row))
}
async fn delete_one(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = pool.begin().await?;
let result = sqlx::query("DELETE FROM artists WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("artist", id));
}
// Link rows cascade via FK; polymorphic metadata is cleaned manually.
sqlx::query("DELETE FROM entity_genre_tags WHERE entity_kind = 'artist' AND entity_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM external_metadata_ids WHERE entity_kind = 'artist' AND entity_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "deleted": id })))
}
+97
View File
@@ -0,0 +1,97 @@
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::{normalize_name, page_limit, page_offset};
pub fn router() -> Router<SqlitePool> {
Router::new()
.route("/", get(list).post(create))
.route("/{id}", get(get_one).delete(delete_one))
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Genre {
pub id: i64,
pub name: String,
pub name_normalized: String,
}
#[derive(Debug, Deserialize)]
struct ListParams {
q: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list(
State(pool): State<SqlitePool>,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Vec<Genre>>> {
let mut qb = sqlx::QueryBuilder::new("SELECT * FROM genres WHERE 1=1");
if let Some(q) = &params.q {
qb.push(" AND name_normalized LIKE ")
.push_bind(format!("%{}%", normalize_name(q)));
}
qb.push(" ORDER BY name_normalized LIMIT ")
.push_bind(page_limit(params.limit))
.push(" OFFSET ")
.push_bind(page_offset(params.offset));
let rows = qb.build_query_as::<Genre>().fetch_all(&pool).await?;
Ok(Json(rows))
}
async fn get_one(State(pool): State<SqlitePool>, Path(id): Path<i64>) -> ApiResult<Json<Genre>> {
sqlx::query_as::<_, Genre>("SELECT * FROM genres WHERE id = ?")
.bind(id)
.fetch_optional(&pool)
.await?
.map(Json)
.ok_or_else(|| ApiError::not_found("genre", id))
}
#[derive(Debug, Deserialize)]
struct CreateGenre {
/// Explicit id for migration; omit for auto-increment.
id: Option<i64>,
name: String,
}
/// Upsert by normalized name: creating an existing genre returns the
/// existing row instead of failing.
async fn create(
State(pool): State<SqlitePool>,
Json(body): Json<CreateGenre>,
) -> ApiResult<Json<Genre>> {
if body.name.trim().is_empty() {
return Err(ApiError::BadRequest("name must not be empty".to_string()));
}
let row = sqlx::query_as::<_, Genre>(
"INSERT INTO genres (id, name, name_normalized) VALUES (?, ?, ?)
ON CONFLICT (name_normalized) DO UPDATE SET name = excluded.name
RETURNING *",
)
.bind(body.id)
.bind(body.name.trim())
.bind(normalize_name(&body.name))
.fetch_one(&pool)
.await?;
Ok(Json(row))
}
async fn delete_one(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<serde_json::Value>> {
let result = sqlx::query("DELETE FROM genres WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("genre", id));
}
Ok(Json(serde_json::json!({ "deleted": id })))
}
+237
View File
@@ -0,0 +1,237 @@
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::{double_option, 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))
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct MediaFile {
pub id: i64,
pub file_type: String,
pub file_path: String,
pub original_filename: String,
pub mime_type: String,
pub file_size_bytes: i64,
pub sha256_hash: String,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i64>,
pub audio_sample_rate: Option<i64>,
pub audio_bit_depth: Option<i64>,
pub uploader_name: String,
pub created_at: String,
}
pub async fn fetch(pool: &SqlitePool, id: i64) -> ApiResult<MediaFile> {
sqlx::query_as::<_, MediaFile>("SELECT * FROM media_files WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::not_found("media file", id))
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
file_type: Option<String>,
sha256: Option<String>,
/// Substring match against file_path
q: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list(
State(pool): State<SqlitePool>,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Vec<MediaFile>>> {
let mut qb = sqlx::QueryBuilder::new("SELECT * FROM media_files WHERE 1=1");
if let Some(file_type) = &params.file_type {
qb.push(" AND file_type = ").push_bind(file_type);
}
if let Some(sha256) = &params.sha256 {
qb.push(" AND sha256_hash = ").push_bind(sha256);
}
if let Some(q) = &params.q {
qb.push(" AND file_path LIKE ").push_bind(format!("%{q}%"));
}
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::<MediaFile>().fetch_all(&pool).await?;
Ok(Json(rows))
}
async fn get_one(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<MediaFile>> {
Ok(Json(fetch(&pool, id).await?))
}
#[derive(Debug, Deserialize)]
struct CreateMediaFile {
/// Explicit id for migration; omit for auto-increment.
id: Option<i64>,
file_type: String,
file_path: String,
#[serde(default)]
original_filename: Option<String>,
#[serde(default)]
mime_type: Option<String>,
#[serde(default)]
file_size_bytes: Option<i64>,
#[serde(default)]
sha256_hash: Option<String>,
audio_format: Option<String>,
audio_bitrate: Option<i64>,
audio_sample_rate: Option<i64>,
audio_bit_depth: Option<i64>,
#[serde(default)]
uploader_name: Option<String>,
/// Override for migration; defaults to now.
created_at: Option<String>,
}
async fn create(
State(pool): State<SqlitePool>,
Json(body): Json<CreateMediaFile>,
) -> ApiResult<Json<MediaFile>> {
if body.file_type != "audio" && body.file_type != "cover_art" {
return Err(ApiError::BadRequest(
"file_type must be 'audio' or 'cover_art'".to_string(),
));
}
let row = sqlx::query_as::<_, MediaFile>(
"INSERT INTO media_files (
id, file_type, file_path, original_filename, mime_type,
file_size_bytes, sha256_hash, audio_format, audio_bitrate,
audio_sample_rate, audio_bit_depth, uploader_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *",
)
.bind(body.id)
.bind(&body.file_type)
.bind(&body.file_path)
.bind(body.original_filename.unwrap_or_default())
.bind(body.mime_type.unwrap_or_default())
.bind(body.file_size_bytes.unwrap_or(0))
.bind(body.sha256_hash.unwrap_or_default())
.bind(&body.audio_format)
.bind(body.audio_bitrate)
.bind(body.audio_sample_rate)
.bind(body.audio_bit_depth)
.bind(body.uploader_name.unwrap_or_else(|| "UFO".to_string()))
.bind(body.created_at.unwrap_or_else(now_iso))
.fetch_one(&pool)
.await?;
Ok(Json(row))
}
#[derive(Debug, Deserialize)]
struct UpdateMediaFile {
file_type: Option<String>,
file_path: Option<String>,
original_filename: Option<String>,
mime_type: Option<String>,
file_size_bytes: Option<i64>,
sha256_hash: Option<String>,
#[serde(default, deserialize_with = "double_option")]
audio_format: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
audio_bitrate: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
audio_sample_rate: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
audio_bit_depth: Option<Option<i64>>,
uploader_name: Option<String>,
}
async fn update(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
Json(body): Json<UpdateMediaFile>,
) -> ApiResult<Json<MediaFile>> {
let mut row = fetch(&pool, id).await?;
if let Some(file_type) = body.file_type {
if file_type != "audio" && file_type != "cover_art" {
return Err(ApiError::BadRequest(
"file_type must be 'audio' or 'cover_art'".to_string(),
));
}
row.file_type = file_type;
}
if let Some(v) = body.file_path {
row.file_path = v;
}
if let Some(v) = body.original_filename {
row.original_filename = v;
}
if let Some(v) = body.mime_type {
row.mime_type = v;
}
if let Some(v) = body.file_size_bytes {
row.file_size_bytes = v;
}
if let Some(v) = body.sha256_hash {
row.sha256_hash = v;
}
if let Some(v) = body.audio_format {
row.audio_format = v;
}
if let Some(v) = body.audio_bitrate {
row.audio_bitrate = v;
}
if let Some(v) = body.audio_sample_rate {
row.audio_sample_rate = v;
}
if let Some(v) = body.audio_bit_depth {
row.audio_bit_depth = v;
}
if let Some(v) = body.uploader_name {
row.uploader_name = v;
}
sqlx::query(
"UPDATE media_files SET file_type = ?, file_path = ?, original_filename = ?,
mime_type = ?, file_size_bytes = ?, sha256_hash = ?, audio_format = ?,
audio_bitrate = ?, audio_sample_rate = ?, audio_bit_depth = ?, uploader_name = ?
WHERE id = ?",
)
.bind(&row.file_type)
.bind(&row.file_path)
.bind(&row.original_filename)
.bind(&row.mime_type)
.bind(row.file_size_bytes)
.bind(&row.sha256_hash)
.bind(&row.audio_format)
.bind(row.audio_bitrate)
.bind(row.audio_sample_rate)
.bind(row.audio_bit_depth)
.bind(&row.uploader_name)
.bind(id)
.execute(&pool)
.await?;
Ok(Json(row))
}
async fn delete_one(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<serde_json::Value>> {
let result = sqlx::query("DELETE FROM media_files WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("media file", id));
}
Ok(Json(serde_json::json!({ "deleted": id })))
}
+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 })))
}
+30
View File
@@ -0,0 +1,30 @@
pub mod artists;
pub mod genres;
pub mod media_files;
pub mod metadata;
pub mod releases;
pub mod tracks;
use axum::routing::get;
use axum::{Json, Router};
use sqlx::SqlitePool;
pub fn api_router() -> Router<SqlitePool> {
Router::new()
.route("/", get(health))
.nest("/artists", artists::router())
.nest("/releases", releases::router())
.nest("/tracks", tracks::router())
.nest("/media-files", media_files::router())
.nest("/genres", genres::router())
.nest("/genre-tags", metadata::genre_tags_router())
.nest("/external-ids", metadata::external_ids_router())
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"name": "furumi-fd",
"version": env!("CARGO_PKG_VERSION"),
"status": "ok",
}))
}
+378
View File
@@ -0,0 +1,378 @@
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<_>>(),
})))
}
+458
View File
@@ -0,0 +1,458 @@
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::{ARTIST_ROLES, 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))
.route("/{id}/genres", put(set_genres))
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Track {
pub id: i64,
pub title: String,
pub title_sort: String,
pub release_id: i64,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub duration_seconds: f64,
pub audio_file_id: i64,
pub cover_file_id: Option<i64>,
pub year: Option<i64>,
pub is_hidden: bool,
pub model_name: Option<String>,
pub lastfm_listeners: Option<i64>,
pub lastfm_playcount: Option<i64>,
pub lastfm_rating: Option<f64>,
pub lastfm_updated_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
pub async fn fetch(pool: &SqlitePool, id: i64) -> ApiResult<Track> {
sqlx::query_as::<_, Track>("SELECT * FROM tracks WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::not_found("track", id))
}
#[derive(Debug, Deserialize)]
struct ListParams {
/// Substring match against title (normalized)
q: Option<String>,
release_id: Option<i64>,
artist_id: Option<i64>,
genre_id: 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<Track>>> {
let mut qb = sqlx::QueryBuilder::new("SELECT t.* FROM tracks t WHERE 1=1");
if let Some(q) = &params.q {
qb.push(" AND t.title_sort LIKE ")
.push_bind(format!("%{}%", normalize_name(q)));
}
if let Some(release_id) = params.release_id {
qb.push(" AND t.release_id = ").push_bind(release_id);
}
if let Some(artist_id) = params.artist_id {
qb.push(
" AND EXISTS (SELECT 1 FROM track_artists ta
WHERE ta.track_id = t.id AND ta.artist_id = ",
)
.push_bind(artist_id)
.push(")");
}
if let Some(genre_id) = params.genre_id {
qb.push(
" AND EXISTS (SELECT 1 FROM track_genres tg
WHERE tg.track_id = t.id AND tg.genre_id = ",
)
.push_bind(genre_id)
.push(")");
}
if let Some(hidden) = params.hidden {
qb.push(" AND t.is_hidden = ").push_bind(hidden);
}
qb.push(" ORDER BY t.release_id, t.disc_number, t.track_number, t.title_sort LIMIT ")
.push_bind(page_limit(params.limit))
.push(" OFFSET ")
.push_bind(page_offset(params.offset));
let rows = qb.build_query_as::<Track>().fetch_all(&pool).await?;
Ok(Json(rows))
}
#[derive(Debug, Serialize, sqlx::FromRow)]
struct TrackArtistOut {
id: i64,
name: String,
role: String,
position: i64,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
struct TrackGenreOut {
id: i64,
name: String,
}
#[derive(Debug, Serialize)]
struct TrackDetail {
#[serde(flatten)]
track: Track,
artists: Vec<TrackArtistOut>,
genres: Vec<TrackGenreOut>,
}
async fn get_one(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> ApiResult<Json<TrackDetail>> {
let track = fetch(&pool, id).await?;
let artists = sqlx::query_as::<_, TrackArtistOut>(
"SELECT a.id, a.name, ta.role, ta.position
FROM artists a
JOIN track_artists ta ON ta.artist_id = a.id
WHERE ta.track_id = ?
ORDER BY ta.position",
)
.bind(id)
.fetch_all(&pool)
.await?;
let genres = sqlx::query_as::<_, TrackGenreOut>(
"SELECT g.id, g.name
FROM genres g
JOIN track_genres tg ON tg.genre_id = g.id
WHERE tg.track_id = ?
ORDER BY g.name_normalized",
)
.bind(id)
.fetch_all(&pool)
.await?;
Ok(Json(TrackDetail {
track,
artists,
genres,
}))
}
#[derive(Debug, Deserialize)]
struct TrackArtistIn {
artist_id: i64,
#[serde(default)]
role: Option<String>,
#[serde(default)]
position: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct CreateTrack {
/// Explicit id for migration; omit for auto-increment.
id: Option<i64>,
title: String,
release_id: i64,
audio_file_id: i64,
track_number: Option<i64>,
disc_number: Option<i64>,
#[serde(default)]
duration_seconds: Option<f64>,
cover_file_id: Option<i64>,
year: Option<i64>,
#[serde(default)]
is_hidden: bool,
model_name: Option<String>,
#[serde(default)]
artists: Vec<TrackArtistIn>,
#[serde(default)]
genre_ids: Vec<i64>,
lastfm_listeners: Option<i64>,
lastfm_playcount: Option<i64>,
lastfm_rating: Option<f64>,
lastfm_updated_at: Option<String>,
/// Overrides for migration; default to now.
created_at: Option<String>,
updated_at: Option<String>,
}
async fn replace_artists(
tx: &mut Transaction<'_, Sqlite>,
track_id: i64,
artists: &[TrackArtistIn],
) -> ApiResult<()> {
sqlx::query("DELETE FROM track_artists WHERE track_id = ?")
.bind(track_id)
.execute(&mut **tx)
.await?;
for (index, entry) in artists.iter().enumerate() {
let role = entry.role.as_deref().unwrap_or("main");
if !ARTIST_ROLES.contains(&role) {
return Err(ApiError::BadRequest(format!(
"role must be one of: {}",
ARTIST_ROLES.join(", ")
)));
}
sqlx::query(
"INSERT INTO track_artists (track_id, artist_id, role, position) VALUES (?, ?, ?, ?)",
)
.bind(track_id)
.bind(entry.artist_id)
.bind(role)
.bind(entry.position.unwrap_or(index as i64))
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn replace_genres(
tx: &mut Transaction<'_, Sqlite>,
track_id: i64,
genre_ids: &[i64],
) -> ApiResult<()> {
sqlx::query("DELETE FROM track_genres WHERE track_id = ?")
.bind(track_id)
.execute(&mut **tx)
.await?;
for genre_id in genre_ids {
sqlx::query("INSERT OR IGNORE INTO track_genres (track_id, genre_id) VALUES (?, ?)")
.bind(track_id)
.bind(genre_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn create(
State(pool): State<SqlitePool>,
Json(body): Json<CreateTrack>,
) -> ApiResult<Json<Track>> {
if body.title.trim().is_empty() {
return Err(ApiError::BadRequest("title must not be empty".to_string()));
}
let now = now_iso();
let mut tx = pool.begin().await?;
let row = sqlx::query_as::<_, Track>(
"INSERT INTO tracks (
id, title, title_sort, release_id, track_number, disc_number,
duration_seconds, audio_file_id, cover_file_id, year, is_hidden,
model_name, lastfm_listeners, lastfm_playcount, lastfm_rating,
lastfm_updated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *",
)
.bind(body.id)
.bind(&body.title)
.bind(normalize_name(&body.title))
.bind(body.release_id)
.bind(body.track_number)
.bind(body.disc_number)
.bind(body.duration_seconds.unwrap_or(0.0))
.bind(body.audio_file_id)
.bind(body.cover_file_id)
.bind(body.year)
.bind(body.is_hidden)
.bind(&body.model_name)
.bind(body.lastfm_listeners)
.bind(body.lastfm_playcount)
.bind(body.lastfm_rating)
.bind(&body.lastfm_updated_at)
.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.artists).await?;
replace_genres(&mut tx, row.id, &body.genre_ids).await?;
tx.commit().await?;
Ok(Json(row))
}
#[derive(Debug, Deserialize)]
struct UpdateTrack {
title: Option<String>,
release_id: Option<i64>,
#[serde(default, deserialize_with = "double_option")]
track_number: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
disc_number: Option<Option<i64>>,
duration_seconds: Option<f64>,
audio_file_id: Option<i64>,
#[serde(default, deserialize_with = "double_option")]
cover_file_id: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
year: Option<Option<i64>>,
is_hidden: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
model_name: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
lastfm_listeners: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
lastfm_playcount: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
lastfm_rating: Option<Option<f64>>,
#[serde(default, deserialize_with = "double_option")]
lastfm_updated_at: Option<Option<String>>,
}
async fn update(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
Json(body): Json<UpdateTrack>,
) -> ApiResult<Json<Track>> {
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(v) = body.release_id {
row.release_id = v;
}
if let Some(v) = body.track_number {
row.track_number = v;
}
if let Some(v) = body.disc_number {
row.disc_number = v;
}
if let Some(v) = body.duration_seconds {
row.duration_seconds = v;
}
if let Some(v) = body.audio_file_id {
row.audio_file_id = v;
}
if let Some(v) = body.cover_file_id {
row.cover_file_id = v;
}
if let Some(v) = body.year {
row.year = v;
}
if let Some(v) = body.is_hidden {
row.is_hidden = v;
}
if let Some(v) = body.model_name {
row.model_name = v;
}
if let Some(v) = body.lastfm_listeners {
row.lastfm_listeners = v;
}
if let Some(v) = body.lastfm_playcount {
row.lastfm_playcount = v;
}
if let Some(v) = body.lastfm_rating {
row.lastfm_rating = v;
}
if let Some(v) = body.lastfm_updated_at {
row.lastfm_updated_at = v;
}
row.updated_at = now_iso();
sqlx::query(
"UPDATE tracks SET title = ?, title_sort = ?, release_id = ?, track_number = ?,
disc_number = ?, duration_seconds = ?, audio_file_id = ?, cover_file_id = ?,
year = ?, is_hidden = ?, model_name = ?, lastfm_listeners = ?,
lastfm_playcount = ?, lastfm_rating = ?, lastfm_updated_at = ?, updated_at = ?
WHERE id = ?",
)
.bind(&row.title)
.bind(&row.title_sort)
.bind(row.release_id)
.bind(row.track_number)
.bind(row.disc_number)
.bind(row.duration_seconds)
.bind(row.audio_file_id)
.bind(row.cover_file_id)
.bind(row.year)
.bind(row.is_hidden)
.bind(&row.model_name)
.bind(row.lastfm_listeners)
.bind(row.lastfm_playcount)
.bind(row.lastfm_rating)
.bind(&row.lastfm_updated_at)
.bind(&row.updated_at)
.bind(id)
.execute(&pool)
.await?;
Ok(Json(row))
}
#[derive(Debug, Deserialize)]
struct SetTrackArtists {
artists: Vec<TrackArtistIn>,
}
async fn set_artists(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
Json(body): Json<SetTrackArtists>,
) -> ApiResult<Json<serde_json::Value>> {
fetch(&pool, id).await?;
let mut tx = pool.begin().await?;
replace_artists(&mut tx, id, &body.artists).await?;
sqlx::query("UPDATE tracks SET updated_at = ? WHERE id = ?")
.bind(now_iso())
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "track_id": id })))
}
#[derive(Debug, Deserialize)]
struct SetTrackGenres {
genre_ids: Vec<i64>,
}
async fn set_genres(
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
Json(body): Json<SetTrackGenres>,
) -> ApiResult<Json<serde_json::Value>> {
fetch(&pool, id).await?;
let mut tx = pool.begin().await?;
replace_genres(&mut tx, id, &body.genre_ids).await?;
tx.commit().await?;
Ok(Json(
serde_json::json!({ "track_id": id, "genre_ids": body.genre_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 result = sqlx::query("DELETE FROM tracks WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("track", id));
}
// Link rows and popularity history cascade via FK; polymorphic metadata
// is cleaned manually.
sqlx::query("DELETE FROM entity_genre_tags WHERE entity_kind = 'track' AND entity_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM external_metadata_ids WHERE entity_kind = 'track' AND entity_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "deleted": id })))
}
+167
View File
@@ -0,0 +1,167 @@
//! SQLite schema for the music library index.
//!
//! Mirrors the library-related primitives of the furumusic database
//! (artists, releases, tracks, media files, genres, genre tags, external
//! metadata ids, popularity) so that data can be migrated 1:1 by an
//! external script. Everything else (users, playlists, playback state,
//! jobs, torrents, scrobbling) is intentionally out of scope.
use sqlx::SqlitePool;
const SCHEMA: &[&str] = &[
// Audio files and cover art on disk. This service only registers
// records — it never reads, writes or deletes the files themselves.
"CREATE TABLE IF NOT EXISTS media_files (
id INTEGER PRIMARY KEY,
file_type TEXT NOT NULL CHECK (file_type IN ('audio', 'cover_art')),
file_path TEXT NOT NULL,
original_filename TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
file_size_bytes INTEGER NOT NULL DEFAULT 0,
sha256_hash TEXT NOT NULL DEFAULT '',
audio_format TEXT,
audio_bitrate INTEGER,
audio_sample_rate INTEGER,
audio_bit_depth INTEGER,
uploader_name TEXT NOT NULL DEFAULT 'UFO',
created_at TEXT NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_media_files_sha256 ON media_files (sha256_hash)",
"CREATE INDEX IF NOT EXISTS idx_media_files_type ON media_files (file_type)",
"CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_sort TEXT NOT NULL,
image_file_id INTEGER REFERENCES media_files(id) ON DELETE SET NULL,
is_hidden INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_artists_name_sort ON artists (name_sort)",
// release_type is one of: album, single, ep, compilation, mixtape,
// live, soundtrack, remix, demo (same vocabulary as furumusic).
"CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
title_sort TEXT NOT NULL,
release_type TEXT NOT NULL DEFAULT 'album',
year INTEGER,
cover_file_id INTEGER REFERENCES media_files(id) ON DELETE SET NULL,
total_tracks INTEGER,
total_discs INTEGER,
is_hidden INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_releases_title_sort ON releases (title_sort)",
"CREATE TABLE IF NOT EXISTS release_artists (
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (release_id, artist_id)
)",
"CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists (artist_id)",
// lastfm_* columns mirror furumusic track popularity fields; they are
// filled by external agents through the regular update API.
"CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
title_sort TEXT NOT NULL,
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
track_number INTEGER,
disc_number INTEGER,
duration_seconds REAL NOT NULL DEFAULT 0,
audio_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE RESTRICT,
cover_file_id INTEGER REFERENCES media_files(id) ON DELETE SET NULL,
year INTEGER,
is_hidden INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
lastfm_listeners INTEGER,
lastfm_playcount INTEGER,
lastfm_rating REAL,
lastfm_updated_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks (release_id)",
"CREATE INDEX IF NOT EXISTS idx_tracks_title_sort ON tracks (title_sort)",
"CREATE INDEX IF NOT EXISTS idx_tracks_audio_file ON tracks (audio_file_id)",
// role is one of: main, featuring, remixer, producer.
"CREATE TABLE IF NOT EXISTS track_artists (
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'main',
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (track_id, artist_id, role)
)",
"CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists (artist_id)",
"CREATE TABLE IF NOT EXISTS genres (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_normalized TEXT NOT NULL UNIQUE
)",
"CREATE TABLE IF NOT EXISTS track_genres (
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
PRIMARY KEY (track_id, genre_id)
)",
// Weighted genre tags per entity (artist/release/track) with a source
// label, as in furumusic__entity_genre_tag. Filled by external agents.
"CREATE TABLE IF NOT EXISTS entity_genre_tags (
id INTEGER PRIMARY KEY,
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('artist', 'release', 'track')),
entity_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
source TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
UNIQUE (entity_kind, entity_id, genre_id, source)
)",
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tags_entity
ON entity_genre_tags (entity_kind, entity_id, source)",
// External identifiers (MusicBrainz, Last.fm, Discogs, ...) per entity,
// as in furumusic__external_metadata_id.
"CREATE TABLE IF NOT EXISTS external_metadata_ids (
id INTEGER PRIMARY KEY,
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('artist', 'release', 'track')),
entity_id INTEGER NOT NULL,
source TEXT NOT NULL,
id_kind TEXT NOT NULL,
external_id TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
UNIQUE (entity_kind, entity_id, source, id_kind)
)",
"CREATE INDEX IF NOT EXISTS idx_external_metadata_ids_lookup
ON external_metadata_ids (source, id_kind, external_id)",
// Popularity snapshots per track, as in furumusic__track_popularity_history.
// Schema kept for migration/agents; no dedicated API endpoints yet.
"CREATE TABLE IF NOT EXISTS track_popularity_history (
id INTEGER PRIMARY KEY,
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
source TEXT NOT NULL,
listeners INTEGER NOT NULL,
playcount INTEGER NOT NULL,
rating REAL NOT NULL,
fetched_at TEXT NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_track_popularity_history_track
ON track_popularity_history (track_id, fetched_at DESC)",
// P2P federation settings: a single row, disabled by default. The
// network name is the only thing peers need to share to find each other.
"CREATE TABLE IF NOT EXISTS federation_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
network_id TEXT NOT NULL DEFAULT ''
)",
"INSERT OR IGNORE INTO federation_settings (id, enabled, network_id) VALUES (1, 0, '')",
];
pub async fn init(pool: &SqlitePool) -> anyhow::Result<()> {
for stmt in SCHEMA {
sqlx::query(*stmt).execute(pool).await?;
}
Ok(())
}
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Deserializer};
/// Same timestamp format as furumusic: `2026-07-16T12:00:00Z`.
pub fn now_iso() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
/// Normalization used for `name_sort` / `title_sort`, same as furumusic.
pub fn normalize_name(name: &str) -> String {
name.trim().to_lowercase()
}
/// Deserializes a field that distinguishes "absent" from "explicit null":
/// absent -> None, null -> Some(None), value -> Some(Some(v)).
pub fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
Deserialize::deserialize(de).map(Some)
}
pub const ENTITY_KINDS: &[&str] = &["artist", "release", "track"];
pub const RELEASE_TYPES: &[&str] = &[
"album",
"single",
"ep",
"compilation",
"mixtape",
"live",
"soundtrack",
"remix",
"demo",
];
pub const ARTIST_ROLES: &[&str] = &["main", "featuring", "remixer", "producer"];
pub fn page_limit(limit: Option<i64>) -> i64 {
limit.unwrap_or(100).clamp(1, 1000)
}
pub fn page_offset(offset: Option<i64>) -> i64 {
offset.unwrap_or(0).max(0)
}