init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
*federation/
|
||||
Generated
+4873
File diff suppressed because it is too large
Load Diff
+19
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "furumi-fd"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Localhost music library index manager: REST API over a local SQLite database"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
# P2P federation: publishes the library index into a DHT and searches other
|
||||
# peers' libraries. Local path dependency on the frid workspace.
|
||||
music-dht = { path = "../../frid/crates/music-dht" }
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -0,0 +1,161 @@
|
||||
# furumi-fd
|
||||
|
||||
Localhost music library **index** manager. A small REST API over a local SQLite
|
||||
database that mirrors the library primitives of the furumusic schema (artists,
|
||||
releases, tracks, media files, genres, weighted genre tags, external metadata
|
||||
ids, popularity), so data can be migrated from furumusic 1:1 by an external
|
||||
script.
|
||||
|
||||
**This service never touches real files.** It only stores records about them.
|
||||
Audio processing, metadata extraction, artwork handling etc. are done by other
|
||||
software that talks to this API.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `FURUMI_FD_DB` | `furumi-fd.sqlite3` | Path to the SQLite database file (created if missing) |
|
||||
| `FURUMI_FD_LISTEN` | `127.0.0.1:8321` | Listen address |
|
||||
| `FURUMI_FD_FEDERATION_DIR` | `<db>.federation` | Directory for the federation identity and DHT state |
|
||||
|
||||
No authentication — localhost only by default.
|
||||
|
||||
`GET /` serves a small built-in web UI (embedded into the binary from
|
||||
`static/index.html`) for browsing, adding and deleting artists, releases,
|
||||
tracks and media file records — plus the **Федерация** page with the
|
||||
federation settings, live statistics and network-wide search.
|
||||
|
||||
## Federation (P2P library search)
|
||||
|
||||
Instances of furumi-fd can form a **federated network**: each publishes its
|
||||
library index (artist names, release titles, track titles and small metadata
|
||||
— never files or file paths) into a distributed hash table and can search
|
||||
the libraries of all other participants. Built on the
|
||||
[`music-dht`](../../frid/crates/music-dht) / `federation-net` crates; peers
|
||||
find each other automatically knowing **only the shared network id** (no
|
||||
bootstrap servers, no invites).
|
||||
|
||||
Federation is **off by default**. Enable it on the *Федерация* page (or via
|
||||
the API): tick the checkbox, enter a network id and save. Every instance
|
||||
using the same id joins the same network; a different id forms a separate,
|
||||
isolated network. The id is effectively a shared secret — pick something
|
||||
unique like `my-crew-music-7f3a`.
|
||||
|
||||
While enabled, the library is re-synchronized into the DHT every minute
|
||||
(added/changed items are republished, removed ones are tombstoned), and the
|
||||
page shows the node ids, connected peers, published item count and the last
|
||||
sync outcome.
|
||||
|
||||
### Federation API
|
||||
|
||||
- `GET /api/federation` — settings + live node status.
|
||||
- `PUT /api/federation/settings` — `{enabled, network_id}`; starts/stops the
|
||||
node immediately and persists the settings across restarts.
|
||||
- `GET /api/federation/search?q=&kind=` — search the network
|
||||
(`kind` optional: `artist | release | track`). Results carry the kind,
|
||||
name, artist names, year/type/duration and the owning peer's id.
|
||||
- `POST /api/federation/sync` — force an immediate library sync.
|
||||
|
||||
## Schema mapping (furumusic → furumi-fd)
|
||||
|
||||
| furumusic (PostgreSQL) | furumi-fd (SQLite) | Notes |
|
||||
|---|---|---|
|
||||
| `furumusic__media_file` | `media_files` | `uploaded_by_user_id` dropped (no users here); `uploader_name` kept |
|
||||
| `furumusic__artist` | `artists` | same fields |
|
||||
| `furumusic__release` | `releases` | same fields, same `release_type` vocabulary |
|
||||
| `furumusic__release_artist` | `release_artists` | surrogate `id` dropped; PK `(release_id, artist_id)` |
|
||||
| `furumusic__track` | `tracks` | includes `lastfm_listeners/playcount/rating/updated_at` |
|
||||
| `furumusic__track_artist` | `track_artists` | surrogate `id` dropped; PK `(track_id, artist_id, role)` |
|
||||
| `furumusic__genre` | `genres` | same fields |
|
||||
| `furumusic__track_genre` | `track_genres` | surrogate `id` dropped; PK `(track_id, genre_id)` |
|
||||
| `furumusic__entity_genre_tag` | `entity_genre_tags` | same fields + unique key |
|
||||
| `furumusic__external_metadata_id` | `external_metadata_ids` | same fields + unique key |
|
||||
| `furumusic__track_popularity_history` | `track_popularity_history` | schema only, no API endpoints yet |
|
||||
|
||||
Shared conventions kept from furumusic:
|
||||
|
||||
- timestamps are `TEXT` in `%Y-%m-%dT%H:%M:%SZ` format;
|
||||
- `name_sort` / `title_sort` = trimmed lowercase of the display value;
|
||||
- `media_files.file_path` is relative to the media root;
|
||||
- `entity_kind` is one of `artist | release | track`;
|
||||
- track artist `role` is one of `main | featuring | remixer | producer`;
|
||||
- `release_type` is one of `album | single | ep | compilation | mixtape | live | soundtrack | remix | demo`.
|
||||
|
||||
### Migration notes
|
||||
|
||||
- Every create endpoint accepts an optional explicit `id`, plus optional
|
||||
`created_at` / `updated_at` overrides — so a migration script can replay
|
||||
rows through the API preserving original ids and timestamps. Insert in FK
|
||||
order: media files → artists → releases (+ artist links) → tracks
|
||||
(+ artist/genre links) → genre tags / external ids.
|
||||
- Alternatively write straight into the SQLite file; the schema is in
|
||||
`src/schema.rs`.
|
||||
|
||||
## REST API
|
||||
|
||||
Base path `/api`. All bodies are JSON. Errors come back as
|
||||
`{"error": "..."}` with 400/404/409/500.
|
||||
|
||||
`GET /api` — health check.
|
||||
|
||||
### Common conventions
|
||||
|
||||
- List endpoints accept `limit` (default 100, max 1000) and `offset`.
|
||||
- `PATCH` is a partial update: absent fields are unchanged; explicit `null`
|
||||
clears a nullable field.
|
||||
- `DELETE` removes index records only, never files on disk.
|
||||
|
||||
### Media files
|
||||
|
||||
- `GET /api/media-files?file_type=&sha256=&q=&limit=&offset=` — `q` matches `file_path`.
|
||||
- `POST /api/media-files` — `{file_type ("audio"|"cover_art"), file_path, original_filename?, mime_type?, file_size_bytes?, sha256_hash?, audio_format?, audio_bitrate?, audio_sample_rate?, audio_bit_depth?, uploader_name?, id?, created_at?}`
|
||||
- `GET | PATCH | DELETE /api/media-files/{id}` — delete is refused (409) while the file is referenced as a track's `audio_file_id`; cover references are cleared automatically.
|
||||
|
||||
### Artists
|
||||
|
||||
- `GET /api/artists?q=&hidden=&limit=&offset=`
|
||||
- `POST /api/artists` — `{name, image_file_id?, is_hidden?, model_name?, id?, created_at?, updated_at?}`
|
||||
- `GET /api/artists/{id}` — artist + releases + track count.
|
||||
- `PATCH /api/artists/{id}`
|
||||
- `DELETE /api/artists/{id}` — releases/tracks stay, artist links and the artist's genre tags / external ids are removed.
|
||||
|
||||
### Releases
|
||||
|
||||
- `GET /api/releases?q=&artist_id=&release_type=&year=&hidden=&limit=&offset=`
|
||||
- `POST /api/releases` — `{title, release_type?, year?, cover_file_id?, total_tracks?, total_discs?, is_hidden?, model_name?, artist_ids?, id?, created_at?, updated_at?}`
|
||||
- `GET /api/releases/{id}` — release + ordered artists + tracks.
|
||||
- `PATCH /api/releases/{id}`
|
||||
- `PUT /api/releases/{id}/artists` — `{artist_ids: [..]}` replaces the artist list (positions by index).
|
||||
- `DELETE /api/releases/{id}` — **deletes the release's tracks too** (index records only).
|
||||
|
||||
### Tracks
|
||||
|
||||
- `GET /api/tracks?q=&release_id=&artist_id=&genre_id=&hidden=&limit=&offset=`
|
||||
- `POST /api/tracks` — `{title, release_id, audio_file_id, track_number?, disc_number?, duration_seconds?, cover_file_id?, year?, is_hidden?, model_name?, artists? [{artist_id, role?, position?}], genre_ids?, lastfm_*?, id?, created_at?, updated_at?}`
|
||||
- `GET /api/tracks/{id}` — track + artists (with roles) + genres.
|
||||
- `PATCH /api/tracks/{id}` — including `lastfm_listeners/playcount/rating/updated_at`.
|
||||
- `PUT /api/tracks/{id}/artists` — `{artists: [{artist_id, role?, position?}]}` replaces links.
|
||||
- `PUT /api/tracks/{id}/genres` — `{genre_ids: [..]}` replaces links.
|
||||
- `DELETE /api/tracks/{id}`
|
||||
|
||||
### Genres
|
||||
|
||||
- `GET /api/genres?q=&limit=&offset=`
|
||||
- `POST /api/genres` — `{name, id?}`; upsert by normalized name (posting an existing genre returns it).
|
||||
- `GET | DELETE /api/genres/{id}`
|
||||
|
||||
### Genre tags (weighted, per source)
|
||||
|
||||
- `GET /api/genre-tags?entity_kind=&entity_id=&genre_id=&source=`
|
||||
- `POST /api/genre-tags` — `{entity_kind, entity_id, genre_id, source, weight?}`; upsert on `(entity_kind, entity_id, genre_id, source)`.
|
||||
- `DELETE /api/genre-tags/{id}`
|
||||
|
||||
### External ids (MusicBrainz, Last.fm, Discogs, …)
|
||||
|
||||
- `GET /api/external-ids?entity_kind=&entity_id=&source=&id_kind=&external_id=`
|
||||
- `POST /api/external-ids` — `{entity_kind, entity_id, source, id_kind, external_id, confidence?}`; upsert on `(entity_kind, entity_id, source, id_kind)`.
|
||||
- `DELETE /api/external-ids/{id}`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms.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
@@ -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(())
|
||||
}
|
||||
@@ -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) = ¶ms.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 })))
|
||||
}
|
||||
@@ -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) = ¶ms.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 })))
|
||||
}
|
||||
@@ -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) = ¶ms.file_type {
|
||||
qb.push(" AND file_type = ").push_bind(file_type);
|
||||
}
|
||||
if let Some(sha256) = ¶ms.sha256 {
|
||||
qb.push(" AND sha256_hash = ").push_bind(sha256);
|
||||
}
|
||||
if let Some(q) = ¶ms.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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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",
|
||||
}))
|
||||
}
|
||||
@@ -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) = ¶ms.q {
|
||||
qb.push(" AND r.title_sort LIKE ")
|
||||
.push_bind(format!("%{}%", normalize_name(q)));
|
||||
}
|
||||
if let Some(release_type) = ¶ms.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<_>>(),
|
||||
})))
|
||||
}
|
||||
@@ -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) = ¶ms.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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>furumi-fd</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #14161a; --panel: #1d2026; --border: #2c313a;
|
||||
--text: #e6e8ec; --muted: #8b93a1; --accent: #6aa5ff; --danger: #e06c75;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: baseline; gap: 16px;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header h1 { margin: 0; font-size: 18px; }
|
||||
header span { color: var(--muted); font-size: 12px; }
|
||||
nav { display: flex; gap: 4px; padding: 10px 20px 0; }
|
||||
nav button {
|
||||
background: none; border: 1px solid transparent; border-bottom: none;
|
||||
color: var(--muted); padding: 8px 16px; cursor: pointer;
|
||||
border-radius: 8px 8px 0 0; font-size: 14px;
|
||||
}
|
||||
nav button.active {
|
||||
background: var(--panel); border-color: var(--border); color: var(--text);
|
||||
}
|
||||
main { padding: 16px 20px 40px; }
|
||||
section { display: none; }
|
||||
section.active { display: block; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
input, select {
|
||||
background: var(--panel); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 6px; padding: 7px 10px; font-size: 14px;
|
||||
}
|
||||
input:focus, select:focus { outline: 1px solid var(--accent); }
|
||||
button.btn {
|
||||
background: var(--accent); border: none; color: #0d1117; font-weight: 600;
|
||||
border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
button.btn.secondary { background: var(--panel); color: var(--text); border: 1px solid var(--border); font-weight: 400; }
|
||||
button.btn.danger { background: none; color: var(--danger); border: 1px solid transparent; padding: 3px 8px; }
|
||||
button.btn.danger:hover { border-color: var(--danger); }
|
||||
button.btn.small { padding: 3px 8px; font-weight: 400; }
|
||||
form.add {
|
||||
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px; margin-bottom: 16px;
|
||||
}
|
||||
form.add label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--muted); }
|
||||
form.add select[multiple] { min-width: 160px; min-height: 64px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
td.actions { text-align: right; white-space: nowrap; }
|
||||
td .muted { color: var(--muted); }
|
||||
#toast {
|
||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--danger); color: #fff; padding: 10px 18px; border-radius: 8px;
|
||||
display: none; max-width: 80vw;
|
||||
}
|
||||
#toast.ok { background: #3f9d5f; }
|
||||
dialog {
|
||||
background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 10px; max-width: min(700px, 90vw); max-height: 80vh;
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,.5); }
|
||||
dialog pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||||
.empty { color: var(--muted); padding: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>furumi-fd</h1><span>менеджер музыкальной библиотеки (только индекс — файлы не трогает)</span></header>
|
||||
<nav id="tabs">
|
||||
<button data-tab="artists" class="active">Артисты</button>
|
||||
<button data-tab="releases">Релизы</button>
|
||||
<button data-tab="tracks">Треки</button>
|
||||
<button data-tab="files">Файлы</button>
|
||||
<button data-tab="federation">Федерация</button>
|
||||
</nav>
|
||||
<main>
|
||||
<section id="tab-artists" class="active">
|
||||
<form class="add" id="artist-form">
|
||||
<label>Имя <input name="name" required placeholder="Boards of Canada"></label>
|
||||
<button class="btn" type="submit">Добавить артиста</button>
|
||||
</form>
|
||||
<div class="toolbar">
|
||||
<input id="artists-q" placeholder="Поиск…">
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th class="num">ID</th><th>Имя</th><th>Создан</th><th></th>
|
||||
</tr></thead><tbody id="artists-body"></tbody></table>
|
||||
<div class="empty" id="artists-empty" hidden>Пусто</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-releases">
|
||||
<form class="add" id="release-form">
|
||||
<label>Название <input name="title" required placeholder="Music Has the Right…"></label>
|
||||
<label>Тип <select name="release_type">
|
||||
<option value="album">Альбом</option><option value="single">Сингл</option>
|
||||
<option value="ep">EP</option><option value="compilation">Сборник</option>
|
||||
<option value="mixtape">Микстейп</option><option value="live">Концерт</option>
|
||||
<option value="soundtrack">Саундтрек</option><option value="remix">Ремикс</option>
|
||||
<option value="demo">Демо</option>
|
||||
</select></label>
|
||||
<label>Год <input name="year" type="number" min="0" max="3000" style="width:90px"></label>
|
||||
<label>Артисты <select name="artist_ids" multiple id="release-artists-select"></select></label>
|
||||
<button class="btn" type="submit">Добавить релиз</button>
|
||||
</form>
|
||||
<div class="toolbar">
|
||||
<input id="releases-q" placeholder="Поиск…">
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th class="num">ID</th><th>Название</th><th>Тип</th><th class="num">Год</th><th>Артисты</th><th></th>
|
||||
</tr></thead><tbody id="releases-body"></tbody></table>
|
||||
<div class="empty" id="releases-empty" hidden>Пусто</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-tracks">
|
||||
<form class="add" id="track-form">
|
||||
<label>Название <input name="title" required placeholder="Wildlife Analysis"></label>
|
||||
<label>Релиз <select name="release_id" required id="track-release-select"></select></label>
|
||||
<label>Аудиофайл <select name="audio_file_id" required id="track-audio-select"></select></label>
|
||||
<label># <input name="track_number" type="number" min="0" style="width:60px"></label>
|
||||
<label>Длит., c <input name="duration_seconds" type="number" min="0" step="0.1" style="width:90px"></label>
|
||||
<label>Артисты <select name="artists" multiple id="track-artists-select"></select></label>
|
||||
<button class="btn" type="submit">Добавить трек</button>
|
||||
</form>
|
||||
<div class="toolbar">
|
||||
<input id="tracks-q" placeholder="Поиск…">
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th class="num">ID</th><th>Название</th><th>Релиз</th><th class="num">#</th><th class="num">Длит.</th><th></th>
|
||||
</tr></thead><tbody id="tracks-body"></tbody></table>
|
||||
<div class="empty" id="tracks-empty" hidden>Пусто</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-files">
|
||||
<form class="add" id="file-form">
|
||||
<label>Тип <select name="file_type">
|
||||
<option value="audio">audio</option><option value="cover_art">cover_art</option>
|
||||
</select></label>
|
||||
<label>Путь (от media root) <input name="file_path" required style="width:340px" placeholder="Artist/Album/01 Track.flac"></label>
|
||||
<label>MIME <input name="mime_type" placeholder="audio/flac" style="width:130px"></label>
|
||||
<button class="btn" type="submit">Добавить файл</button>
|
||||
</form>
|
||||
<div class="toolbar">
|
||||
<input id="files-q" placeholder="Поиск по пути…">
|
||||
<select id="files-type">
|
||||
<option value="">все типы</option>
|
||||
<option value="audio">audio</option>
|
||||
<option value="cover_art">cover_art</option>
|
||||
</select>
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th class="num">ID</th><th>Тип</th><th>Путь</th><th>MIME</th><th></th>
|
||||
</tr></thead><tbody id="files-body"></tbody></table>
|
||||
<div class="empty" id="files-empty" hidden>Пусто</div>
|
||||
</section>
|
||||
<section id="tab-federation">
|
||||
<form class="add" id="federation-form">
|
||||
<label style="flex-direction:row;align-items:center;gap:8px;font-size:14px;color:var(--text)">
|
||||
<input type="checkbox" name="enabled" id="fed-enabled" style="width:auto"> Включить федерацию
|
||||
</label>
|
||||
<label>ID сети (общий секрет участников)
|
||||
<input name="network_id" id="fed-network" placeholder="my-music-network-x7f3" style="width:260px">
|
||||
</label>
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
<button class="btn secondary" type="button" id="fed-sync-now">Синхронизировать сейчас</button>
|
||||
</form>
|
||||
|
||||
<div id="fed-status" class="empty">Загрузка…</div>
|
||||
|
||||
<h3 style="margin:20px 0 8px">Поиск по сети</h3>
|
||||
<div class="toolbar">
|
||||
<input id="fed-q" placeholder="Артист, релиз или трек…" style="width:280px">
|
||||
<select id="fed-kind">
|
||||
<option value="">все типы</option>
|
||||
<option value="artist">артисты</option>
|
||||
<option value="release">релизы</option>
|
||||
<option value="track">треки</option>
|
||||
</select>
|
||||
<button class="btn" id="fed-search-btn">Искать</button>
|
||||
<span id="fed-search-meta" style="color:var(--muted);align-self:center"></span>
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th>Тип</th><th>Название</th><th>Артисты</th><th class="num">Год</th><th>Детали</th><th>Владелец</th>
|
||||
</tr></thead><tbody id="fed-results-body"></tbody></table>
|
||||
<div class="empty" id="fed-results-empty" hidden>Ничего не найдено</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast"></div>
|
||||
<dialog id="detail-dialog">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<strong id="detail-title"></strong>
|
||||
<button class="btn secondary small" onclick="document.getElementById('detail-dialog').close()">закрыть</button>
|
||||
</div>
|
||||
<pre id="detail-body"></pre>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const API = '/api';
|
||||
const LIMIT = 500;
|
||||
|
||||
// ---------- helpers ----------
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(API + path, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || res.status + ' ' + res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(message, ok = false) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = message;
|
||||
el.className = ok ? 'ok' : '';
|
||||
el.style.display = 'block';
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.style.display = 'none'; }, ok ? 2500 : 6000);
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value ?? '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function debounce(fn, ms = 300) {
|
||||
let timer;
|
||||
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
async function showDetail(title, path) {
|
||||
try {
|
||||
const data = await api(path);
|
||||
document.getElementById('detail-title').textContent = title;
|
||||
document.getElementById('detail-body').textContent = JSON.stringify(data, null, 2);
|
||||
document.getElementById('detail-dialog').showModal();
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
function rowButtons(kind, id) {
|
||||
return `<td class="actions">
|
||||
<button class="btn secondary small" onclick="viewItem('${kind}', ${id})">инфо</button>
|
||||
<button class="btn danger" onclick="deleteItem('${kind}', ${id})">удалить</button>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
const KIND_PATHS = { artist: 'artists', release: 'releases', track: 'tracks', file: 'media-files' };
|
||||
const KIND_NAMES = { artist: 'Артист', release: 'Релиз', track: 'Трек', file: 'Файл' };
|
||||
const KIND_CONFIRM = {
|
||||
artist: 'Удалить артиста? Его релизы и треки останутся, снимутся только связи.',
|
||||
release: 'Удалить релиз? Его треки тоже будут удалены из базы (файлы не трогаются).',
|
||||
track: 'Удалить трек из базы? Файл на диске не трогается.',
|
||||
file: 'Удалить запись о файле? Сам файл на диске не трогается.',
|
||||
};
|
||||
|
||||
function viewItem(kind, id) {
|
||||
showDetail(`${KIND_NAMES[kind]} #${id}`, `/${KIND_PATHS[kind]}/${id}`);
|
||||
}
|
||||
|
||||
async function deleteItem(kind, id) {
|
||||
if (!confirm(KIND_CONFIRM[kind])) return;
|
||||
try {
|
||||
await api(`/${KIND_PATHS[kind]}/${id}`, { method: 'DELETE' });
|
||||
toast(`${KIND_NAMES[kind]} #${id} удалён`, true);
|
||||
refreshAll();
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
function fmtDuration(seconds) {
|
||||
if (!seconds) return '';
|
||||
const m = Math.floor(seconds / 60), s = Math.round(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderRows(tbodyId, emptyId, rows, renderRow) {
|
||||
document.getElementById(tbodyId).innerHTML = rows.map(renderRow).join('');
|
||||
document.getElementById(emptyId).hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
function fillSelect(select, items, label, { placeholder } = {}) {
|
||||
const selected = new Set([...select.selectedOptions].map(o => o.value));
|
||||
select.innerHTML = (placeholder ? `<option value="">${placeholder}</option>` : '')
|
||||
+ items.map(i => `<option value="${i.id}" ${selected.has(String(i.id)) ? 'selected' : ''}>${esc(label(i))}</option>`).join('');
|
||||
}
|
||||
|
||||
// ---------- data caches (for name lookups and selects) ----------
|
||||
let artistsCache = [], releasesCache = [], audioFilesCache = [];
|
||||
|
||||
// ---------- loaders ----------
|
||||
async function loadArtists() {
|
||||
const q = document.getElementById('artists-q').value.trim();
|
||||
const rows = await api(`/artists?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
||||
if (!q) artistsCache = rows;
|
||||
renderRows('artists-body', 'artists-empty', rows, a => `<tr>
|
||||
<td class="num">${a.id}</td>
|
||||
<td>${esc(a.name)}</td>
|
||||
<td class="muted">${esc(a.created_at)}</td>
|
||||
${rowButtons('artist', a.id)}
|
||||
</tr>`);
|
||||
const artistLabel = a => `${a.name} (#${a.id})`;
|
||||
fillSelect(document.getElementById('release-artists-select'), artistsCache, artistLabel);
|
||||
fillSelect(document.getElementById('track-artists-select'), artistsCache, artistLabel);
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
const q = document.getElementById('releases-q').value.trim();
|
||||
const rows = await api(`/releases?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
||||
if (!q) releasesCache = rows;
|
||||
// artist names per release come from the detail endpoint; to keep the list
|
||||
// fast we show them only when artistsCache can't be avoided — fetch lazily.
|
||||
renderRows('releases-body', 'releases-empty', rows, r => `<tr>
|
||||
<td class="num">${r.id}</td>
|
||||
<td>${esc(r.title)}</td>
|
||||
<td class="muted">${esc(r.release_type)}</td>
|
||||
<td class="num">${r.year ?? ''}</td>
|
||||
<td class="muted" id="release-artists-${r.id}">…</td>
|
||||
${rowButtons('release', r.id)}
|
||||
</tr>`);
|
||||
fillSelect(document.getElementById('track-release-select'), releasesCache,
|
||||
r => `${r.title}${r.year ? ' (' + r.year + ')' : ''} (#${r.id})`,
|
||||
{ placeholder: '— релиз —' });
|
||||
fillReleaseArtists(rows);
|
||||
}
|
||||
|
||||
async function fillReleaseArtists(rows) {
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const detail = await api(`/releases/${r.id}`);
|
||||
const cell = document.getElementById(`release-artists-${r.id}`);
|
||||
if (cell) cell.textContent = detail.artists.map(a => a.name).join(', ');
|
||||
} catch { /* list may have been re-rendered */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTracks() {
|
||||
const q = document.getElementById('tracks-q').value.trim();
|
||||
const rows = await api(`/tracks?limit=${LIMIT}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
||||
const releaseTitles = Object.fromEntries(releasesCache.map(r => [r.id, r.title]));
|
||||
renderRows('tracks-body', 'tracks-empty', rows, t => `<tr>
|
||||
<td class="num">${t.id}</td>
|
||||
<td>${esc(t.title)}</td>
|
||||
<td class="muted">${esc(releaseTitles[t.release_id] ?? '#' + t.release_id)}</td>
|
||||
<td class="num">${t.track_number ?? ''}</td>
|
||||
<td class="num">${fmtDuration(t.duration_seconds)}</td>
|
||||
${rowButtons('track', t.id)}
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
const q = document.getElementById('files-q').value.trim();
|
||||
const type = document.getElementById('files-type').value;
|
||||
const rows = await api(`/media-files?limit=${LIMIT}`
|
||||
+ (q ? '&q=' + encodeURIComponent(q) : '')
|
||||
+ (type ? '&file_type=' + type : ''));
|
||||
if (!q && !type) audioFilesCache = rows.filter(f => f.file_type === 'audio');
|
||||
renderRows('files-body', 'files-empty', rows, f => `<tr>
|
||||
<td class="num">${f.id}</td>
|
||||
<td class="muted">${esc(f.file_type)}</td>
|
||||
<td>${esc(f.file_path)}</td>
|
||||
<td class="muted">${esc(f.mime_type)}</td>
|
||||
${rowButtons('file', f.id)}
|
||||
</tr>`);
|
||||
fillSelect(document.getElementById('track-audio-select'), audioFilesCache,
|
||||
f => `${f.file_path} (#${f.id})`, { placeholder: '— аудиофайл —' });
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
try {
|
||||
await loadArtists();
|
||||
await loadFiles();
|
||||
await loadReleases();
|
||||
await loadTracks();
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
// ---------- forms ----------
|
||||
function onSubmit(formId, handler) {
|
||||
document.getElementById(formId).addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
try {
|
||||
await handler(new FormData(form), form);
|
||||
form.reset();
|
||||
toast('Добавлено', true);
|
||||
refreshAll();
|
||||
} catch (err) { toast(err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit('artist-form', fd =>
|
||||
api('/artists', { method: 'POST', body: JSON.stringify({ name: fd.get('name') }) }));
|
||||
|
||||
onSubmit('release-form', (fd, form) => {
|
||||
const artistIds = [...form.querySelector('[name=artist_ids]').selectedOptions].map(o => +o.value);
|
||||
return api('/releases', { method: 'POST', body: JSON.stringify({
|
||||
title: fd.get('title'),
|
||||
release_type: fd.get('release_type'),
|
||||
year: fd.get('year') ? +fd.get('year') : null,
|
||||
artist_ids: artistIds,
|
||||
}) });
|
||||
});
|
||||
|
||||
onSubmit('track-form', (fd, form) => {
|
||||
const artists = [...form.querySelector('[name=artists]').selectedOptions]
|
||||
.map(o => ({ artist_id: +o.value, role: 'main' }));
|
||||
return api('/tracks', { method: 'POST', body: JSON.stringify({
|
||||
title: fd.get('title'),
|
||||
release_id: +fd.get('release_id'),
|
||||
audio_file_id: +fd.get('audio_file_id'),
|
||||
track_number: fd.get('track_number') ? +fd.get('track_number') : null,
|
||||
duration_seconds: fd.get('duration_seconds') ? +fd.get('duration_seconds') : 0,
|
||||
artists,
|
||||
}) });
|
||||
});
|
||||
|
||||
onSubmit('file-form', fd =>
|
||||
api('/media-files', { method: 'POST', body: JSON.stringify({
|
||||
file_type: fd.get('file_type'),
|
||||
file_path: fd.get('file_path'),
|
||||
mime_type: fd.get('mime_type') || '',
|
||||
}) }));
|
||||
|
||||
// ---------- federation ----------
|
||||
const FED_KIND_NAMES = { artist: 'артист', release: 'релиз', track: 'трек' };
|
||||
|
||||
function shortId(id) { return id ? id.slice(0, 12) + '…' : ''; }
|
||||
|
||||
function renderFedStatus(data) {
|
||||
document.getElementById('fed-enabled').checked = data.settings.enabled;
|
||||
const networkInput = document.getElementById('fed-network');
|
||||
if (document.activeElement !== networkInput) networkInput.value = data.settings.network_id;
|
||||
|
||||
const el = document.getElementById('fed-status');
|
||||
if (!data.node.running) {
|
||||
el.innerHTML = data.settings.enabled
|
||||
? 'Узел не запущен.' + (data.last_error ? ' Ошибка: ' + esc(data.last_error) : '')
|
||||
: 'Федерация выключена. Включите её и укажите ID сети — все инстансы с тем же ID найдут друг друга автоматически.';
|
||||
return;
|
||||
}
|
||||
const n = data.node;
|
||||
const sync = data.last_sync
|
||||
? `${data.last_sync.at} (+${data.last_sync.added} / ~${data.last_sync.updated} / −${data.last_sync.removed}, без изменений ${data.last_sync.unchanged})`
|
||||
: 'ещё не было';
|
||||
el.innerHTML = `
|
||||
<table style="max-width:720px">
|
||||
<tr><td class="muted">Сеть</td><td>${esc(n.network)}</td></tr>
|
||||
<tr><td class="muted">Endpoint ID</td><td title="${esc(n.endpoint_id)}">${esc(shortId(n.endpoint_id))}</td></tr>
|
||||
<tr><td class="muted">Подключено пиров</td><td>${n.connected_peers.length}${n.connected_peers.length ? ' — ' + n.connected_peers.map(shortId).map(esc).join(', ') : ''}</td></tr>
|
||||
<tr><td class="muted">Известно контактов</td><td>${n.known_contacts}</td></tr>
|
||||
<tr><td class="muted">Опубликовано записей</td><td>${n.published_items}</td></tr>
|
||||
<tr><td class="muted">Последняя синхронизация</td><td>${esc(sync)}</td></tr>
|
||||
${data.last_error ? `<tr><td class="muted">Ошибка</td><td style="color:var(--danger)">${esc(data.last_error)}</td></tr>` : ''}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function loadFederation() {
|
||||
try { renderFedStatus(await api('/federation')); }
|
||||
catch (err) { document.getElementById('fed-status').textContent = err.message; }
|
||||
}
|
||||
|
||||
document.getElementById('federation-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const data = await api('/federation/settings', { method: 'PUT', body: JSON.stringify({
|
||||
enabled: document.getElementById('fed-enabled').checked,
|
||||
network_id: document.getElementById('fed-network').value.trim(),
|
||||
}) });
|
||||
renderFedStatus(data);
|
||||
toast('Настройки федерации сохранены', true);
|
||||
} catch (err) { toast(err.message); }
|
||||
});
|
||||
|
||||
document.getElementById('fed-sync-now').addEventListener('click', async () => {
|
||||
try {
|
||||
renderFedStatus(await api('/federation/sync', { method: 'POST' }));
|
||||
toast('Синхронизация выполнена', true);
|
||||
} catch (err) { toast(err.message); }
|
||||
});
|
||||
|
||||
function fedDetails(r) {
|
||||
if (r.kind === 'release') return [r.release_type, r.year].filter(Boolean).join(', ');
|
||||
if (r.kind === 'track') return fmtDuration(r.duration_seconds);
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fedSearch() {
|
||||
const q = document.getElementById('fed-q').value.trim();
|
||||
if (!q) return;
|
||||
const kind = document.getElementById('fed-kind').value;
|
||||
const meta = document.getElementById('fed-search-meta');
|
||||
meta.textContent = 'ищем…';
|
||||
try {
|
||||
const data = await api(`/federation/search?q=${encodeURIComponent(q)}${kind ? '&kind=' + kind : ''}`);
|
||||
renderRows('fed-results-body', 'fed-results-empty', data.results, r => `<tr>
|
||||
<td class="muted">${FED_KIND_NAMES[r.kind] ?? esc(r.kind)}</td>
|
||||
<td>${esc(r.name)}</td>
|
||||
<td class="muted">${esc((r.artist_names || []).join(', '))}</td>
|
||||
<td class="num">${r.year ?? ''}</td>
|
||||
<td class="muted">${esc(fedDetails(r))}</td>
|
||||
<td class="muted" title="${esc(r.owner)}">${r.own ? 'вы' : esc(shortId(r.owner))}</td>
|
||||
</tr>`);
|
||||
meta.textContent = `узлов опрошено: ${data.queried_nodes}, ${data.duration_ms} мс`;
|
||||
} catch (err) {
|
||||
meta.textContent = '';
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('fed-search-btn').addEventListener('click', fedSearch);
|
||||
document.getElementById('fed-q').addEventListener('keydown', e => { if (e.key === 'Enter') fedSearch(); });
|
||||
|
||||
// Poll the status while the federation tab is visible.
|
||||
let fedTimer = null;
|
||||
function setFederationPolling(active) {
|
||||
clearInterval(fedTimer);
|
||||
fedTimer = null;
|
||||
if (active) {
|
||||
loadFederation();
|
||||
fedTimer = setInterval(loadFederation, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- search & tabs ----------
|
||||
document.getElementById('artists-q').addEventListener('input', debounce(loadArtists));
|
||||
document.getElementById('releases-q').addEventListener('input', debounce(loadReleases));
|
||||
document.getElementById('tracks-q').addEventListener('input', debounce(loadTracks));
|
||||
document.getElementById('files-q').addEventListener('input', debounce(loadFiles));
|
||||
document.getElementById('files-type').addEventListener('change', loadFiles);
|
||||
|
||||
document.getElementById('tabs').addEventListener('click', event => {
|
||||
const button = event.target.closest('button[data-tab]');
|
||||
if (!button) return;
|
||||
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('active', b === button));
|
||||
document.querySelectorAll('main section').forEach(s =>
|
||||
s.classList.toggle('active', s.id === 'tab-' + button.dataset.tab));
|
||||
setFederationPolling(button.dataset.tab === 'federation');
|
||||
});
|
||||
|
||||
refreshAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user