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