Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e | ||
|
|
4381750c6e | ||
|
|
3485f643f4 | ||
|
|
bca0f5e2f0 | ||
|
|
53b2ff29f8 | ||
|
|
c349512fb0 | ||
|
|
0615356785 | ||
|
|
184371afca | ||
|
|
716da908c9 |
@@ -2,3 +2,4 @@
|
||||
/nul
|
||||
/.claude
|
||||
/media
|
||||
/federation
|
||||
|
||||
Generated
+2538
-587
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -1,11 +1,14 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.4.7"
|
||||
version = "0.6.6-fd"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
[dependencies]
|
||||
cot = { version = "0.6.0", features = ["postgres", "json", "openapi", "swagger-ui"] }
|
||||
# default-features off: cot's defaults include the sqlite backend, whose old
|
||||
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
|
||||
# binary). This server only ever talks PostgreSQL.
|
||||
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json", "openapi", "swagger-ui"] }
|
||||
schemars = { version = "0.9", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
openidconnect = "4.0"
|
||||
@@ -13,6 +16,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||
tower = "0.5"
|
||||
base64 = "0.22"
|
||||
blake3 = "1"
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -28,5 +32,9 @@ anyhow = "1.0"
|
||||
tokio-cron-scheduler = "0.15"
|
||||
croner = "3"
|
||||
async-trait = "0.1"
|
||||
postcard = { version = "1", features = ["alloc"] }
|
||||
uuid = "1"
|
||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
|
||||
@@ -415,6 +415,38 @@ impl App for AdminApp {
|
||||
}),
|
||||
"admin_v2_settings_probe",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation",
|
||||
get(move |session: Session, db: Database| async move {
|
||||
v2::federation_status(session, db).await
|
||||
}),
|
||||
"admin_v2_federation_status",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation/sync",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
v2::federation_sync(session, db).await
|
||||
}),
|
||||
"admin_v2_federation_sync",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation/ticket",
|
||||
get(move |session: Session, db: Database| async move {
|
||||
v2::federation_ticket(session, db).await
|
||||
}),
|
||||
"admin_v2_federation_ticket",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation/connect",
|
||||
cot::router::method::post(
|
||||
move |session: Session,
|
||||
db: Database,
|
||||
json: Json<v2::FederationConnectRequest>| async move {
|
||||
v2::federation_connect(session, db, json).await
|
||||
},
|
||||
),
|
||||
"admin_v2_federation_connect",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/jobs/{name}/toggle",
|
||||
cot::router::method::post({
|
||||
|
||||
@@ -448,6 +448,10 @@ struct AdminSettingsValues {
|
||||
agent_confidence_threshold: String,
|
||||
agent_context_limit: String,
|
||||
agent_concurrency: String,
|
||||
#[serde(default)]
|
||||
federation_enabled: bool,
|
||||
#[serde(default)]
|
||||
federation_network_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
@@ -472,6 +476,8 @@ struct AdminSettingsSources {
|
||||
agent_confidence_threshold: &'static str,
|
||||
agent_context_limit: &'static str,
|
||||
agent_concurrency: &'static str,
|
||||
federation_enabled: &'static str,
|
||||
federation_network_id: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -496,6 +502,10 @@ pub(super) struct UpdateSettingsRequest {
|
||||
agent_confidence_threshold: String,
|
||||
agent_context_limit: String,
|
||||
agent_concurrency: String,
|
||||
#[serde(default)]
|
||||
federation_enabled: bool,
|
||||
#[serde(default)]
|
||||
federation_network_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -978,6 +988,11 @@ pub async fn update_settings(
|
||||
"agent_concurrency",
|
||||
body.agent_concurrency.trim().to_string(),
|
||||
),
|
||||
("federation_enabled", body.federation_enabled.to_string()),
|
||||
(
|
||||
"federation_network_id",
|
||||
body.federation_network_id.trim().to_string(),
|
||||
),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||
@@ -986,9 +1001,78 @@ pub async fn update_settings(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
// Federation applies on the fly: (re)start or stop the node to match
|
||||
// the freshly saved settings — no server restart involved.
|
||||
let (fresh, _) = AppConfig::load_with_db(&db).await;
|
||||
tokio::spawn(async move {
|
||||
crate::federation::handle().apply(&fresh).await;
|
||||
});
|
||||
Json(serde_json::json!({ "ok": true })).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Federation (status + manual controls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn federation_status(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
Json(crate::federation::handle().status().await).into_response()
|
||||
}
|
||||
|
||||
pub async fn federation_sync(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let fed = crate::federation::handle();
|
||||
if let Err(err) = fed.sync_now().await {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("sync failed: {err:#}"),
|
||||
));
|
||||
}
|
||||
Json(fed.status().await).into_response()
|
||||
}
|
||||
|
||||
pub async fn federation_ticket(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
match crate::federation::handle().ticket().await {
|
||||
Ok(ticket) => Json(serde_json::json!({ "ticket": ticket })).into_response(),
|
||||
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FederationConnectRequest {
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
pub async fn federation_connect(
|
||||
session: Session,
|
||||
db: Database,
|
||||
Json(body): Json<FederationConnectRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
match crate::federation::handle().connect(&body.ticket).await {
|
||||
Ok(peer) => Json(serde_json::json!({ "connected": peer })).into_response(),
|
||||
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn settings_probe(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1057,6 +1141,8 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
|
||||
agent_context_limit: config.agent_context_limit.to_string(),
|
||||
agent_concurrency: config.agent_concurrency.to_string(),
|
||||
federation_enabled: config.federation_enabled,
|
||||
federation_network_id: config.federation_network_id,
|
||||
},
|
||||
sources: AdminSettingsSources {
|
||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||
@@ -1079,6 +1165,8 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
agent_confidence_threshold: sources.agent_confidence_threshold.code(),
|
||||
agent_context_limit: sources.agent_context_limit.code(),
|
||||
agent_concurrency: sources.agent_concurrency.code(),
|
||||
federation_enabled: sources.federation_enabled.code(),
|
||||
federation_network_id: sources.federation_network_id.code(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ pub struct ConfigSources {
|
||||
pub agent_concurrency: ConfigSource,
|
||||
pub lastfm_api_key: ConfigSource,
|
||||
pub lastfm_shared_secret: ConfigSource,
|
||||
pub federation_enabled: ConfigSource,
|
||||
pub federation_network_id: ConfigSource,
|
||||
}
|
||||
|
||||
impl Default for ConfigSources {
|
||||
@@ -162,6 +164,8 @@ impl Default for ConfigSources {
|
||||
agent_concurrency: ConfigSource::Default,
|
||||
lastfm_api_key: ConfigSource::Default,
|
||||
lastfm_shared_secret: ConfigSource::Default,
|
||||
federation_enabled: ConfigSource::Default,
|
||||
federation_network_id: ConfigSource::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +274,12 @@ pub struct AppConfig {
|
||||
pub lastfm_api_key: String,
|
||||
/// Last.fm shared secret for authenticated scrobbling calls.
|
||||
pub lastfm_shared_secret: String,
|
||||
/// Whether this server participates in the furumi federation (publishes
|
||||
/// its library into the shared DHT and serves audio to peers).
|
||||
pub federation_enabled: bool,
|
||||
/// Federation network id — the shared secret every peer of the network
|
||||
/// uses to find the others.
|
||||
pub federation_network_id: String,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -297,6 +307,8 @@ impl Default for AppConfig {
|
||||
agent_concurrency: 2,
|
||||
lastfm_api_key: String::new(),
|
||||
lastfm_shared_secret: String::new(),
|
||||
federation_enabled: false,
|
||||
federation_network_id: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +337,8 @@ impl_env_overrides!(
|
||||
agent_concurrency,
|
||||
lastfm_api_key,
|
||||
lastfm_shared_secret,
|
||||
federation_enabled,
|
||||
federation_network_id,
|
||||
);
|
||||
|
||||
impl AppConfig {
|
||||
@@ -452,6 +466,8 @@ impl AppConfig {
|
||||
apply_db_field!(agent_concurrency);
|
||||
apply_db_field!(lastfm_api_key);
|
||||
apply_db_field!(lastfm_shared_secret);
|
||||
apply_db_field!(federation_enabled);
|
||||
apply_db_field!(federation_network_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//! P2P federation for the furumusic server.
|
||||
//!
|
||||
//! When enabled in the admin settings, the server becomes a regular peer of
|
||||
//! the furumi federation: it publishes its whole visible library (artists,
|
||||
//! releases, tracks — names and small metadata, never files) into the
|
||||
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||
//! clients speak among themselves. Serve-only: the server does not search
|
||||
//! or download from other peers.
|
||||
//!
|
||||
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||
//! `federation_network_id`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
|
||||
mod serve;
|
||||
mod storage;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{
|
||||
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats,
|
||||
RendezvousConfig, SyncStats,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row as _;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use storage::PostgresFederationStorage;
|
||||
|
||||
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||
|
||||
/// How often the published library is re-synchronized with the database.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
struct Running {
|
||||
service: Arc<MusicDhtService>,
|
||||
network_name: String,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct ContentHashJob {
|
||||
media_file_id: i64,
|
||||
sha256_hash: String,
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
pub struct Federation {
|
||||
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||
data_dir: PathBuf,
|
||||
database_url: std::sync::Mutex<String>,
|
||||
storage_dir: std::sync::Mutex<String>,
|
||||
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||
pool: tokio::sync::OnceCell<PgPool>,
|
||||
running: tokio::sync::Mutex<Option<Running>>,
|
||||
last_sync: std::sync::Mutex<Option<String>>,
|
||||
last_error: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The process-wide federation handle.
|
||||
pub fn handle() -> Arc<Federation> {
|
||||
static HANDLE: OnceLock<Arc<Federation>> = OnceLock::new();
|
||||
Arc::clone(HANDLE.get_or_init(|| {
|
||||
Arc::new(Federation {
|
||||
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
|
||||
database_url: std::sync::Mutex::new(String::new()),
|
||||
storage_dir: std::sync::Mutex::new(String::new()),
|
||||
content_cache: std::sync::Mutex::new(Default::default()),
|
||||
content_pending: std::sync::Mutex::new(Default::default()),
|
||||
pool: tokio::sync::OnceCell::new(),
|
||||
running: tokio::sync::Mutex::new(None),
|
||||
last_sync: std::sync::Mutex::new(None),
|
||||
last_error: std::sync::Mutex::new(None),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
impl Federation {
|
||||
fn set_error(&self, message: Option<String>) {
|
||||
*lock(&self.last_error) = message;
|
||||
}
|
||||
|
||||
async fn pool(&self) -> Result<PgPool> {
|
||||
let url = lock(&self.database_url).clone();
|
||||
anyhow::ensure!(!url.is_empty(), "database is not configured");
|
||||
let pool = self
|
||||
.pool
|
||||
.get_or_try_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect(&url)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
Ok(pool.clone())
|
||||
}
|
||||
|
||||
/// Starts the node at boot when federation was left enabled. The
|
||||
/// settings live in the config KV table, so this waits for the database
|
||||
/// and resolves the same default → DB → env precedence the config uses.
|
||||
pub async fn boot(self: &Arc<Self>, config: &AppConfig) {
|
||||
*lock(&self.database_url) = config.database_url.clone();
|
||||
if config.database_url.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pool = match self.pool().await {
|
||||
Ok(pool) => pool,
|
||||
Err(err) => {
|
||||
tracing::warn!("federation boot: database unavailable: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// `config` carries defaults + env; overlay the DB rows for fields
|
||||
// that have no env override (env > DB > default).
|
||||
let mut effective = config.clone();
|
||||
let rows = sqlx::query(
|
||||
"SELECT key, value FROM furumusic__config_entry
|
||||
WHERE key IN ('federation_enabled', 'federation_network_id', 'agent_storage_dir')",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for row in rows {
|
||||
let key: String = row.get(0);
|
||||
let value: String = row.get(1);
|
||||
let env_key = format!("FURU_{}", key.to_ascii_uppercase());
|
||||
if std::env::var(&env_key).is_ok() {
|
||||
continue;
|
||||
}
|
||||
match key.as_str() {
|
||||
"federation_enabled" => {
|
||||
if let Ok(parsed) = value.parse() {
|
||||
effective.federation_enabled = parsed;
|
||||
}
|
||||
}
|
||||
"federation_network_id" => effective.federation_network_id = value,
|
||||
"agent_storage_dir" => {
|
||||
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.apply(&effective).await;
|
||||
}
|
||||
|
||||
/// Applies the effective configuration: starts, stops or re-joins the
|
||||
/// node. Called at boot and every time the admin settings are saved.
|
||||
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||
*lock(&self.database_url) = config.database_url.clone();
|
||||
*lock(&self.storage_dir) = config.agent_storage_dir.clone();
|
||||
let network = config.federation_network_id.trim().to_string();
|
||||
if config.federation_enabled && !network.is_empty() {
|
||||
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
|
||||
tracing::error!("federation start failed: {err:#}");
|
||||
self.set_error(Some(format!("start failed: {err}")));
|
||||
}
|
||||
} else {
|
||||
self.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the DHT node. Idempotent per network name; a node on another
|
||||
/// network is stopped and re-joined.
|
||||
async fn start(self: &Arc<Self>, network_name: String, storage_dir: String) -> Result<()> {
|
||||
let pool = self.pool().await?;
|
||||
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 dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||
let secret_key = dht_storage.load_or_create_secret_key().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())
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) =
|
||||
MusicDhtService::start_with_storage_and_secret_key(config, dht_storage, secret_key)
|
||||
.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.
|
||||
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 database.
|
||||
let sync_self = Arc::clone(self);
|
||||
let sync_service = Arc::clone(&service);
|
||||
let sync_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let _ = sync_self.sync_once(&sync_service).await;
|
||||
}
|
||||
});
|
||||
// Serve audio and catalog requests from other peers.
|
||||
let audio_acceptor = service
|
||||
.stream_acceptor(AUDIO_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?;
|
||||
let audio_task = tokio::spawn(serve::serve_audio(
|
||||
audio_acceptor,
|
||||
pool.clone(),
|
||||
storage_dir.clone(),
|
||||
service.endpoint_id(),
|
||||
));
|
||||
let catalog_acceptor = service
|
||||
.stream_acceptor(CATALOG_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
||||
let catalog_task = tokio::spawn(serve::serve_catalog(
|
||||
catalog_acceptor,
|
||||
pool,
|
||||
storage_dir,
|
||||
service.endpoint_id(),
|
||||
));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
network_name,
|
||||
tasks: vec![event_task, sync_task, audio_task, catalog_task],
|
||||
});
|
||||
self.set_error(None);
|
||||
drop(guard);
|
||||
// Publish right away instead of waiting for the first timer tick.
|
||||
self.spawn_sync_soon().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) {
|
||||
let mut guard = self.running.lock().await;
|
||||
stop_running(guard.take()).await;
|
||||
}
|
||||
|
||||
async fn service(&self) -> Result<Arc<MusicDhtService>> {
|
||||
self.running
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|running| Arc::clone(&running.service))
|
||||
.context("federation is not running")
|
||||
}
|
||||
|
||||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||
if let Ok(service) = self.service().await {
|
||||
let fed = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let _ = fed.sync_once(&service).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
||||
let service = self.service().await?;
|
||||
let sync_stats = self.sync_once(&service).await?;
|
||||
let publish_stats = match service.republish().await {
|
||||
Ok(stats) => stats,
|
||||
Err(err) => {
|
||||
tracing::warn!("federation republish failed: {err}");
|
||||
self.set_error(Some(format!("republish failed: {err}")));
|
||||
anyhow::bail!("republish failed: {err}");
|
||||
}
|
||||
};
|
||||
self.record_publish_success(sync_stats, publish_stats);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_once(self: &Arc<Self>, service: &MusicDhtService) -> Result<SyncStats> {
|
||||
let specs = match self.collect_specs().await {
|
||||
Ok(specs) => specs,
|
||||
Err(err) => {
|
||||
tracing::warn!("federation sync: library read failed: {err:#}");
|
||||
self.set_error(Some(format!("library read failed: {err}")));
|
||||
anyhow::bail!("library read failed: {err}");
|
||||
}
|
||||
};
|
||||
match service.sync_library(specs).await {
|
||||
Ok(stats) => {
|
||||
self.record_sync_success(stats);
|
||||
if stats.failed > 0 {
|
||||
self.set_error(Some(format!(
|
||||
"{} item(s) failed to publish in the last sync",
|
||||
stats.failed
|
||||
)));
|
||||
} else {
|
||||
self.set_error(None);
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("federation sync failed: {err}");
|
||||
self.set_error(Some(format!("sync failed: {err}")));
|
||||
Err(anyhow::anyhow!("sync failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_sync_success(&self, stats: SyncStats) {
|
||||
*lock(&self.last_sync) = Some(format!(
|
||||
"{} (+{} ~{} −{}, unchanged {}, failed {})",
|
||||
now_iso(),
|
||||
stats.added,
|
||||
stats.updated,
|
||||
stats.removed,
|
||||
stats.unchanged,
|
||||
stats.failed
|
||||
));
|
||||
}
|
||||
|
||||
fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) {
|
||||
*lock(&self.last_sync) = Some(format!(
|
||||
"{} (+{} ~{} −{}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})",
|
||||
now_iso(),
|
||||
sync_stats.added,
|
||||
sync_stats.updated,
|
||||
sync_stats.removed,
|
||||
sync_stats.unchanged,
|
||||
sync_stats.failed,
|
||||
publish_stats.records,
|
||||
publish_stats.keys,
|
||||
publish_stats.remote_nodes,
|
||||
));
|
||||
self.set_error(None);
|
||||
}
|
||||
|
||||
/// Everything the regular player shows, as DHT item specs: non-hidden
|
||||
/// artists, releases and tracks (a track also hides with its release).
|
||||
async fn collect_specs(self: &Arc<Self>) -> Result<Vec<ItemSpec>> {
|
||||
let pool = self.pool().await?;
|
||||
let mut specs = Vec::new();
|
||||
|
||||
let artists = sqlx::query("SELECT id, name FROM furumusic__artist WHERE is_hidden = false")
|
||||
.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(),
|
||||
featured_artist_names: Vec::new(),
|
||||
year: None,
|
||||
release_type: None,
|
||||
release_title: None,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: None,
|
||||
content_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
let release_artists = sqlx::query(
|
||||
"SELECT ra.release_id, a.name FROM furumusic__release_artist ra
|
||||
JOIN furumusic__artist 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 furumusic__release
|
||||
WHERE is_hidden = false",
|
||||
)
|
||||
.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(),
|
||||
featured_artist_names: Vec::new(),
|
||||
year: row.get(2),
|
||||
release_type: row.get(3),
|
||||
release_title: None,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: None,
|
||||
content_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
let track_artists = sqlx::query(
|
||||
"SELECT ta.track_id, a.name, ta.role FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.role IN ('main', 'featuring')
|
||||
ORDER BY ta.track_id,
|
||||
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
|
||||
ta.position",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||
let mut featured_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||
for row in &track_artists {
|
||||
let id: i64 = row.get(0);
|
||||
let name: String = row.get(1);
|
||||
if row.get::<String, _>(2) == "featuring" {
|
||||
featured_of_track.entry(id).or_default().push(name);
|
||||
} else {
|
||||
artists_of_track.entry(id).or_default().push(name);
|
||||
}
|
||||
}
|
||||
let tracks = sqlx::query(
|
||||
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds,
|
||||
r.title, r.release_type, t.track_number, t.disc_number,
|
||||
t.audio_file_id, m.file_path, m.sha256_hash, c.content_id
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__federation_content_id_cache c
|
||||
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
let storage_dir = lock(&self.storage_dir).clone();
|
||||
let mut content_hash_jobs = Vec::new();
|
||||
for row in &tracks {
|
||||
let id: i64 = row.get(0);
|
||||
let duration: f64 = row.get(3);
|
||||
let media_file_id: i64 = row.get(8);
|
||||
let file_path: String = row.get(9);
|
||||
let sha256_hash: String = row.get(10);
|
||||
let cached_content_id: Option<String> = row.get(11);
|
||||
let content_id = cached_content_id
|
||||
.or_else(|| self.cached_content_id_for_media(media_file_id, &sha256_hash));
|
||||
if content_id.is_none()
|
||||
&& !storage_dir.trim().is_empty()
|
||||
&& self.mark_content_hash_pending(media_file_id)
|
||||
{
|
||||
content_hash_jobs.push(ContentHashJob {
|
||||
media_file_id,
|
||||
sha256_hash,
|
||||
file_path,
|
||||
});
|
||||
}
|
||||
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(),
|
||||
featured_artist_names: featured_of_track.remove(&id).unwrap_or_default(),
|
||||
year: row.get(2),
|
||||
release_type: row.get(5),
|
||||
release_title: Some(row.get(4)),
|
||||
track_number: row.get(6),
|
||||
disc_number: row.get(7),
|
||||
duration_seconds: (duration > 0.0).then_some(duration),
|
||||
content_id,
|
||||
});
|
||||
}
|
||||
self.spawn_content_warmer(pool.clone(), storage_dir, content_hash_jobs);
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
fn cached_content_id_for_media(&self, media_file_id: i64, sha256_hash: &str) -> Option<String> {
|
||||
if let Some((cached_hash, content_id)) = lock(&self.content_cache).get(&media_file_id)
|
||||
&& cached_hash == sha256_hash
|
||||
{
|
||||
return Some(content_id.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mark_content_hash_pending(&self, media_file_id: i64) -> bool {
|
||||
lock(&self.content_pending).insert(media_file_id)
|
||||
}
|
||||
|
||||
fn spawn_content_warmer(
|
||||
self: &Arc<Self>,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
jobs: Vec<ContentHashJob>,
|
||||
) {
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fed = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let total = jobs.len();
|
||||
let mut stored = 0usize;
|
||||
for job in jobs {
|
||||
let job_storage_dir = storage_dir.clone();
|
||||
let job_file_path = job.file_path.clone();
|
||||
let content_id = tokio::task::spawn_blocking(move || {
|
||||
audio_content_id(&job_storage_dir, &job_file_path)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
lock(&fed.content_pending).remove(&job.media_file_id);
|
||||
if let Some(content_id) = content_id {
|
||||
lock(&fed.content_cache).insert(
|
||||
job.media_file_id,
|
||||
(job.sha256_hash.clone(), content_id.clone()),
|
||||
);
|
||||
if let Err(err) =
|
||||
persist_content_id(&pool, job.media_file_id, &job.sha256_hash, &content_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
media_file_id = job.media_file_id,
|
||||
"federation content-id cache write failed: {err:#}"
|
||||
);
|
||||
} else {
|
||||
stored += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(total, stored, "federation content-id cache warm finished");
|
||||
});
|
||||
}
|
||||
|
||||
/// Live status for the admin page.
|
||||
pub async fn status(&self) -> Value {
|
||||
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(),
|
||||
"connected_peers": peers,
|
||||
"known_contacts": service.known_peers().len(),
|
||||
"published_items": published,
|
||||
})
|
||||
}
|
||||
None => json!({ "running": false }),
|
||||
};
|
||||
json!({
|
||||
"node": node,
|
||||
"last_sync": lock(&self.last_sync).clone(),
|
||||
"last_error": lock(&self.last_error).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn ticket(&self) -> Result<String> {
|
||||
let service = self.service().await?;
|
||||
let ticket = service
|
||||
.ticket()
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot create a ticket: {err}"))?;
|
||||
Ok(ticket.to_string())
|
||||
}
|
||||
|
||||
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
||||
let service = self.service().await?;
|
||||
let ticket: PeerTicket = ticket
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|err| anyhow::anyhow!("malformed ticket: {err}"))?;
|
||||
let peer = service
|
||||
.connect(ticket)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||||
Ok(peer.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_content_id(
|
||||
pool: &PgPool,
|
||||
media_file_id: i64,
|
||||
sha256_hash: &str,
|
||||
content_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_content_id_cache
|
||||
(media_file_id, sha256_hash, content_id, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||
sha256_hash = EXCLUDED.sha256_hash,
|
||||
content_id = EXCLUDED.content_id,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
)
|
||||
.bind(media_file_id)
|
||||
.bind(sha256_hash)
|
||||
.bind(content_id)
|
||||
.bind(now_iso())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
|
||||
if storage_dir.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||
let mut file = std::fs::File::open(path).ok()?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
//! Serve side of the federation wire protocols (audio + catalog), backed by
|
||||
//! the PostgreSQL library and the media storage directory. Wire compatible
|
||||
//! with the furumi TUI client and any other furumi peer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row as _;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
/// ALPN of the per-artist catalog protocol.
|
||||
pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1";
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
/// Images above this size are skipped rather than transferred.
|
||||
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire shapes (shared with the furumi TUI client)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AudioRequest {
|
||||
item_id: String,
|
||||
#[serde(default)]
|
||||
offset: u64,
|
||||
#[serde(default)]
|
||||
want_cover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct AudioResponseHeader {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
mime_type: String,
|
||||
total_size: u64,
|
||||
offset: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
metadata: Option<TrackMetadata>,
|
||||
cover_size: u64,
|
||||
cover_mime: String,
|
||||
artist_image_size: u64,
|
||||
artist_image_mime: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
struct TrackMetadata {
|
||||
title: String,
|
||||
artists: Vec<String>,
|
||||
featured_artists: Vec<String>,
|
||||
album_artists: Vec<String>,
|
||||
release_title: String,
|
||||
release_type: Option<String>,
|
||||
year: Option<i32>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CatalogRequest {
|
||||
artist: String,
|
||||
#[serde(default)]
|
||||
want: Option<String>,
|
||||
#[serde(default)]
|
||||
release: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogResponse {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
artist: Option<CatalogArtist>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogArtist {
|
||||
name: String,
|
||||
releases: Vec<CatalogRelease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogRelease {
|
||||
title: String,
|
||||
release_type: String,
|
||||
year: Option<i32>,
|
||||
tracks: Vec<CatalogTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogTrack {
|
||||
title: String,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
duration_seconds: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_id: Option<String>,
|
||||
item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct ImageHeader {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
mime_type: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||
if value.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(ItemId::from_bytes(bytes))
|
||||
}
|
||||
|
||||
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = reader.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("stream ended before the protocol line was complete");
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
return Ok(line);
|
||||
}
|
||||
line.push(byte[0]);
|
||||
if line.len() > MAX_PROTOCOL_LINE {
|
||||
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||
writer: &mut W,
|
||||
value: &impl Serialize,
|
||||
) -> Result<()> {
|
||||
let mut line = serde_json::to_vec(value)?;
|
||||
line.push(b'\n');
|
||||
writer.write_all(&line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn item_id_of(own: &EndpointId, track_id: i64) -> String {
|
||||
hex_encode(ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")).as_bytes())
|
||||
}
|
||||
|
||||
fn resolve_media_path(storage_dir: &str, file_path: &str) -> PathBuf {
|
||||
crate::media_paths::resolve_media_file_path(storage_dir, file_path)
|
||||
}
|
||||
|
||||
fn guess_mime(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"opus" => "audio/opus",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||
"aac" => "audio/aac",
|
||||
"aiff" | "aif" => "audio/aiff",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an image media file from disk, bounded by [`MAX_IMAGE_BYTES`].
|
||||
async fn read_image(
|
||||
storage_dir: &str,
|
||||
media: Option<(String, String)>,
|
||||
) -> Option<(Vec<u8>, String)> {
|
||||
let (file_path, mime) = media?;
|
||||
let path = resolve_media_path(storage_dir, &file_path);
|
||||
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
||||
if size == 0 || size > MAX_IMAGE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||
let mime = if mime.trim().is_empty() {
|
||||
"image/jpeg".to_string()
|
||||
} else {
|
||||
mime
|
||||
};
|
||||
Some((bytes, mime))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library lookups (PostgreSQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Finds the visible track whose derived DHT item id matches `item_id`.
|
||||
async fn resolve_track_id(pool: &PgPool, own: &EndpointId, item_id: ItemId) -> Result<Option<i64>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT t.id FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
if ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")) == item_id {
|
||||
return Ok(Some(track_id));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// (file_path, mime_type) of the track's audio media file.
|
||||
async fn track_audio_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
/// Track cover (falling back to the release cover) as (file_path, mime).
|
||||
async fn track_cover_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
JOIN furumusic__media_file m ON m.id = COALESCE(t.cover_file_id, r.cover_file_id)
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
/// The main artist's image of a track as (file_path, mime).
|
||||
async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||
WHERE ta.track_id = $1 AND ta.role = 'main'
|
||||
ORDER BY ta.position LIMIT 1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||
let Some(track) = sqlx::query(
|
||||
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
||||
t.release_id, r.title, r.release_type
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let release_id: i64 = track.get(4);
|
||||
|
||||
let mut artists = Vec::new();
|
||||
let mut featured = Vec::new();
|
||||
let artist_rows = sqlx::query(
|
||||
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in artist_rows {
|
||||
let name: String = row.get(0);
|
||||
match row.get::<String, _>(1).as_str() {
|
||||
"featuring" => featured.push(name),
|
||||
"main" => artists.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let album_artists: Vec<String> = sqlx::query(
|
||||
"SELECT a.name FROM furumusic__release_artist ra
|
||||
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||
WHERE ra.release_id = $1 ORDER BY ra.position",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| row.get(0))
|
||||
.collect();
|
||||
|
||||
Ok(Some(TrackMetadata {
|
||||
title: track.get(0),
|
||||
artists,
|
||||
featured_artists: featured,
|
||||
album_artists,
|
||||
release_title: track.get(5),
|
||||
release_type: Some(track.get(6)),
|
||||
year: track.get(3),
|
||||
track_number: track.get(1),
|
||||
disc_number: track.get(2),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the audio accept loop until the acceptor closes. Every visible
|
||||
/// track of the library is streamable by every peer of the network.
|
||||
pub async fn serve_audio(
|
||||
mut acceptor: StreamAcceptor,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own).await {
|
||||
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_audio_one(
|
||||
mut stream: ByteStream,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
) -> Result<()> {
|
||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
offset = request.offset,
|
||||
"federation peer requested audio"
|
||||
);
|
||||
|
||||
let track_id = match hex_decode_item_id(&request.item_id) {
|
||||
Some(item_id) => match resolve_track_id(&pool, &own, item_id).await {
|
||||
Ok(Some(track_id)) => track_id,
|
||||
Ok(None) => return refuse_audio(stream, "track not found in the library").await,
|
||||
Err(err) => {
|
||||
return refuse_audio(stream, &format!("library lookup failed: {err:#}")).await;
|
||||
}
|
||||
},
|
||||
None => return refuse_audio(stream, "malformed item_id").await,
|
||||
};
|
||||
|
||||
let Some((file_path, mime_type)) = track_audio_file(&pool, track_id).await? else {
|
||||
return refuse_audio(stream, "audio file record is missing").await;
|
||||
};
|
||||
let path = resolve_media_path(&storage_dir, &file_path);
|
||||
let mut file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return refuse_audio(stream, &format!("audio file is not readable: {err}")).await;
|
||||
}
|
||||
};
|
||||
let total_size = file.metadata().await?.len();
|
||||
let offset = request.offset.min(total_size);
|
||||
if offset > 0 {
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
}
|
||||
|
||||
let metadata = match track_metadata(&pool, track_id).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
tracing::warn!(track_id, "federation metadata lookup failed: {err:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let (cover, artist_image) = if request.want_cover {
|
||||
(
|
||||
read_image(
|
||||
&storage_dir,
|
||||
track_cover_file(&pool, track_id).await.ok().flatten(),
|
||||
)
|
||||
.await,
|
||||
read_image(
|
||||
&storage_dir,
|
||||
track_artist_image_file(&pool, track_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mime_type = if mime_type.trim().is_empty() {
|
||||
guess_mime(&path).to_string()
|
||||
} else {
|
||||
mime_type
|
||||
};
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type,
|
||||
total_size,
|
||||
offset,
|
||||
metadata,
|
||||
cover_size: cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||
cover_mime: cover
|
||||
.as_ref()
|
||||
.map(|(_, mime)| mime.clone())
|
||||
.unwrap_or_default(),
|
||||
artist_image_size: artist_image
|
||||
.as_ref()
|
||||
.map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||
artist_image_mime: artist_image
|
||||
.as_ref()
|
||||
.map(|(_, mime)| mime.clone())
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some((bytes, _)) = &cover {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
if let Some((bytes, _)) = &artist_image {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||
stream.send.finish()?;
|
||||
// Wait until the peer read everything before dropping the stream,
|
||||
// otherwise the tail of the file is lost.
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refuse_audio(mut stream: ByteStream, message: &str) -> Result<()> {
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: false,
|
||||
error: Some(message.to_string()),
|
||||
..AudioResponseHeader::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
anyhow::bail!("refused audio request: {message}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the catalog accept loop until the acceptor closes.
|
||||
pub async fn serve_catalog(
|
||||
mut acceptor: StreamAcceptor,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_catalog_one(stream, pool, storage_dir, own).await {
|
||||
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_catalog_one(
|
||||
mut stream: ByteStream,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
) -> Result<()> {
|
||||
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
artist = %request.artist,
|
||||
want = request.want.as_deref().unwrap_or("catalog"),
|
||||
"federation peer requested a catalog"
|
||||
);
|
||||
|
||||
match request.want.as_deref() {
|
||||
None | Some("catalog") => {
|
||||
let response = match build_catalog(&pool, &own, &request.artist).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||
artist: None,
|
||||
},
|
||||
};
|
||||
stream
|
||||
.send
|
||||
.write_all(&serde_json::to_vec(&response)?)
|
||||
.await?;
|
||||
}
|
||||
Some(want @ ("artist_image" | "release_cover")) => {
|
||||
let media = if want == "release_cover" {
|
||||
release_cover_by_names(
|
||||
&pool,
|
||||
&request.artist,
|
||||
request.release.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
artist_image_by_name(&pool, &request.artist).await?
|
||||
};
|
||||
let image = read_image(&storage_dir, media).await;
|
||||
let header = match &image {
|
||||
Some((bytes, mime)) => ImageHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type: mime.clone(),
|
||||
size: bytes.len() as u64,
|
||||
},
|
||||
None => ImageHeader {
|
||||
ok: false,
|
||||
error: Some("no image".to_string()),
|
||||
..ImageHeader::default()
|
||||
},
|
||||
};
|
||||
write_line(&mut stream.send, &header).await?;
|
||||
if let Some((bytes, _)) = &image {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
let response = CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("unknown request kind '{other}'")),
|
||||
artist: None,
|
||||
};
|
||||
stream
|
||||
.send
|
||||
.write_all(&serde_json::to_vec(&response)?)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||
let Some(artist_row) = sqlx::query(
|
||||
"SELECT id, name FROM furumusic__artist
|
||||
WHERE LOWER(name) = LOWER($1) AND is_hidden = false
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(artist)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
else {
|
||||
return Ok(CatalogResponse {
|
||||
ok: false,
|
||||
error: Some("artist not found in the library".to_string()),
|
||||
artist: None,
|
||||
});
|
||||
};
|
||||
let artist_id: i64 = artist_row.get(0);
|
||||
|
||||
let release_rows = sqlx::query(
|
||||
"SELECT r.id, r.title, r.release_type, r.year
|
||||
FROM furumusic__release r
|
||||
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||
WHERE ra.artist_id = $1 AND r.is_hidden = false
|
||||
ORDER BY r.year NULLS LAST, r.title",
|
||||
)
|
||||
.bind(artist_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut releases = Vec::new();
|
||||
for release_row in release_rows {
|
||||
let release_id: i64 = release_row.get(0);
|
||||
let track_rows = sqlx::query(
|
||||
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
|
||||
c.content_id
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__federation_content_id_cache c
|
||||
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||
for row in track_rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
let duration: f64 = row.get(4);
|
||||
tracks.push(CatalogTrack {
|
||||
title: row.get(1),
|
||||
track_number: row.get(2),
|
||||
disc_number: row.get(3),
|
||||
duration_seconds: (duration > 0.0).then_some(duration),
|
||||
content_id: row.get(5),
|
||||
item_id: item_id_of(own, track_id),
|
||||
});
|
||||
}
|
||||
releases.push(CatalogRelease {
|
||||
title: release_row.get(1),
|
||||
release_type: release_row.get(2),
|
||||
year: release_row.get(3),
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
error: None,
|
||||
artist: Some(CatalogArtist {
|
||||
name: artist_row.get(1),
|
||||
releases,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn artist_image_by_name(pool: &PgPool, artist: &str) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__artist a
|
||||
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||
WHERE LOWER(a.name) = LOWER($1) AND a.is_hidden = false
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(artist)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
async fn release_cover_by_names(
|
||||
pool: &PgPool,
|
||||
artist: &str,
|
||||
release: &str,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__release r
|
||||
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||
JOIN furumusic__media_file m ON m.id = r.cover_file_id
|
||||
WHERE LOWER(a.name) = LOWER($1) AND LOWER(r.title) = LOWER($2)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(artist)
|
||||
.bind(release)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use music_dht::{
|
||||
DhtKey, EndpointId, LibraryItem, MAX_RECORDS_PER_RESPONSE, MusicDhtError, MusicDhtStorage,
|
||||
NodeContact, NodeId, SecretKey, StoreDecision, StoredRecord, decide_store,
|
||||
};
|
||||
use sqlx::{PgPool, Row as _};
|
||||
|
||||
const IDENTITY_NAME: &str = "default";
|
||||
|
||||
const SCHEMA: &[&str] = &[
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_identity (
|
||||
name TEXT PRIMARY KEY,
|
||||
secret_key BYTEA NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_local_item (
|
||||
id BYTEA PRIMARY KEY,
|
||||
normalized_name TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
payload BYTEA NOT NULL
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_local_item_normalized_name
|
||||
ON furumusic__federation_local_item(normalized_name)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_dht_record (
|
||||
dht_key BYTEA NOT NULL,
|
||||
item_id BYTEA NOT NULL,
|
||||
owner_peer_id TEXT NOT NULL,
|
||||
payload BYTEA NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
deleted BOOLEAN NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (dht_key, item_id, owner_peer_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_dht_record_expires_at
|
||||
ON furumusic__federation_dht_record(expires_at_ms)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_known_peer (
|
||||
peer_id TEXT PRIMARY KEY,
|
||||
node_id BYTEA NOT NULL,
|
||||
ticket TEXT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||
media_file_id BIGINT PRIMARY KEY,
|
||||
sha256_hash TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||
ON furumusic__federation_content_id_cache(content_id)",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostgresFederationStorage {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresFederationStorage {
|
||||
pub async fn new(pool: PgPool) -> music_dht::Result<Self> {
|
||||
let storage = Self { pool };
|
||||
storage.ensure_schema().await?;
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
pub async fn load_or_create_secret_key(&self) -> music_dht::Result<SecretKey> {
|
||||
if let Some(bytes) = sqlx::query_scalar::<_, Vec<u8>>(
|
||||
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||
)
|
||||
.bind(IDENTITY_NAME)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
{
|
||||
return secret_from_bytes(bytes);
|
||||
}
|
||||
|
||||
let key = SecretKey::generate();
|
||||
let key_bytes = key.to_bytes();
|
||||
let now = now_iso();
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO furumusic__federation_identity
|
||||
(name, secret_key, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (name) DO NOTHING",
|
||||
)
|
||||
.bind(IDENTITY_NAME)
|
||||
.bind(key_bytes.as_slice())
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.rows_affected();
|
||||
if inserted == 1 {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let bytes = sqlx::query_scalar::<_, Vec<u8>>(
|
||||
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||
)
|
||||
.bind(IDENTITY_NAME)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
secret_from_bytes(bytes)
|
||||
}
|
||||
|
||||
async fn ensure_schema(&self) -> music_dht::Result<()> {
|
||||
for sql in SCHEMA {
|
||||
sqlx::query(sql)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicDhtStorage for PostgresFederationStorage {
|
||||
async fn upsert_local_item(&self, item: &LibraryItem) -> music_dht::Result<()> {
|
||||
let payload = postcard::to_stdvec(item).map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_local_item
|
||||
(id, normalized_name, revision, deleted, updated_at_ms, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
normalized_name = EXCLUDED.normalized_name,
|
||||
revision = EXCLUDED.revision,
|
||||
deleted = EXCLUDED.deleted,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms,
|
||||
payload = EXCLUDED.payload",
|
||||
)
|
||||
.bind(item.id.as_bytes().as_slice())
|
||||
.bind(&item.normalized_name)
|
||||
.bind(item.revision as i64)
|
||||
.bind(item.deleted)
|
||||
.bind(item.updated_at_ms as i64)
|
||||
.bind(payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_local_items(&self, include_deleted: bool) -> music_dht::Result<Vec<LibraryItem>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT payload
|
||||
FROM furumusic__federation_local_item
|
||||
WHERE $1 OR deleted = false
|
||||
ORDER BY normalized_name",
|
||||
)
|
||||
.bind(include_deleted)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| postcard::from_bytes::<LibraryItem>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
|
||||
let existing = sqlx::query(
|
||||
"SELECT revision, deleted, expires_at_ms
|
||||
FROM furumusic__federation_dht_record
|
||||
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>(0) as u64,
|
||||
row.get::<bool, _>(1),
|
||||
row.get::<i64, _>(2) as u64,
|
||||
)
|
||||
});
|
||||
|
||||
match decide_store(existing, &record) {
|
||||
StoreDecision::Ignore => return Ok(false),
|
||||
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||
}
|
||||
|
||||
let payload = postcard::to_stdvec(&record).map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_dht_record
|
||||
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||
payload = EXCLUDED.payload,
|
||||
revision = EXCLUDED.revision,
|
||||
deleted = EXCLUDED.deleted,
|
||||
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.bind(payload)
|
||||
.bind(record.item.revision as i64)
|
||||
.bind(record.item.deleted)
|
||||
.bind(record.expires_at_ms as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn dht_records_by_key(
|
||||
&self,
|
||||
key: DhtKey,
|
||||
now_ms: u64,
|
||||
) -> music_dht::Result<Vec<StoredRecord>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT payload
|
||||
FROM furumusic__federation_dht_record
|
||||
WHERE dht_key = $1 AND expires_at_ms > $2
|
||||
ORDER BY expires_at_ms DESC, item_id
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(now_ms as i64)
|
||||
.bind(MAX_RECORDS_PER_RESPONSE as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| postcard::from_bytes::<StoredRecord>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete_expired_records(&self, now_ms: u64) -> music_dht::Result<usize> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM furumusic__federation_dht_record WHERE expires_at_ms <= $1")
|
||||
.bind(now_ms as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
async fn upsert_known_peer(&self, contact: &NodeContact) -> music_dht::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_known_peer
|
||||
(peer_id, node_id, ticket, last_seen_ms)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (peer_id) DO UPDATE SET
|
||||
node_id = EXCLUDED.node_id,
|
||||
ticket = EXCLUDED.ticket,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms",
|
||||
)
|
||||
.bind(contact.peer_id.to_string())
|
||||
.bind(contact.node_id.as_bytes().as_slice())
|
||||
.bind(&contact.ticket)
|
||||
.bind(contact.last_seen_ms as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_known_peer(&self, peer_id: EndpointId) -> music_dht::Result<()> {
|
||||
sqlx::query("DELETE FROM furumusic__federation_known_peer WHERE peer_id = $1")
|
||||
.bind(peer_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_known_peers(&self) -> music_dht::Result<Vec<NodeContact>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT peer_id, node_id, ticket, last_seen_ms
|
||||
FROM furumusic__federation_known_peer",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
let peer_id: String = row.get(0);
|
||||
let node_id: Vec<u8> = row.get(1);
|
||||
let ticket: String = row.get(2);
|
||||
let last_seen_ms: i64 = row.get(3);
|
||||
let Ok(peer_id) = EndpointId::from_str(&peer_id) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(node_id) = <[u8; 32]>::try_from(node_id.as_slice()) else {
|
||||
continue;
|
||||
};
|
||||
contacts.push(NodeContact {
|
||||
node_id: NodeId::from_bytes(node_id),
|
||||
peer_id,
|
||||
ticket,
|
||||
last_seen_ms: last_seen_ms as u64,
|
||||
});
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
fn secret_from_bytes(bytes: Vec<u8>) -> music_dht::Result<SecretKey> {
|
||||
let bytes: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| MusicDhtError::Database("stored federation identity is corrupted".into()))?;
|
||||
Ok(SecretKey::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
|
||||
MusicDhtError::Database(err.to_string())
|
||||
}
|
||||
+121
-8
@@ -12,6 +12,21 @@ const AUDIO_EXTENSIONS: &[&str] = &[
|
||||
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
||||
];
|
||||
|
||||
/// How long a `failed` review must stay untouched before discover
|
||||
/// automatically requeues it (instead of creating a new row per attempt).
|
||||
const FAILED_RETRY_COOLDOWN_SECS: i64 = 3600;
|
||||
|
||||
/// Leftover files that are safe to purge from inbox folders that no longer
|
||||
/// contain any audio (covers, playlists, rip logs and similar sidecar files).
|
||||
const JUNK_EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "m3u", "m3u8", "cue", "log", "txt", "nfo", "sfv",
|
||||
"md5", "accurip", "url", "ini", "pdf",
|
||||
];
|
||||
const JUNK_FILENAMES: &[&str] = &[".ds_store", "thumbs.db", "desktop.ini"];
|
||||
|
||||
/// Junk younger than this is kept — an upload might still be in progress.
|
||||
const JUNK_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
|
||||
|
||||
pub struct InboxDiscoverJob;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -76,6 +91,10 @@ impl Job for InboxDiscoverJob {
|
||||
let mut audio_files = Vec::new();
|
||||
collect_audio_files(inbox, &mut audio_files).await?;
|
||||
|
||||
// Purge leftover junk (covers, playlists, logs) from subtrees that no
|
||||
// longer contain audio, so processed uploads don't linger forever.
|
||||
cleanup_inbox_junk(inbox, JUNK_MIN_AGE).await;
|
||||
|
||||
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
||||
if audio_files.is_empty() {
|
||||
return Ok(());
|
||||
@@ -87,6 +106,7 @@ impl Job for InboxDiscoverJob {
|
||||
let mut discovered = 0u64;
|
||||
let mut skipped_hash = 0u64;
|
||||
let mut skipped_existing = 0u64;
|
||||
let mut requeued = 0u64;
|
||||
|
||||
for (_folder, files) in &groups {
|
||||
for file_path in files {
|
||||
@@ -94,13 +114,34 @@ impl Job for InboxDiscoverJob {
|
||||
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
|
||||
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||
|
||||
// Skip if a PendingReview already exists for this path
|
||||
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
|
||||
Ok(true) => {
|
||||
skipped_existing += 1;
|
||||
// One review row per path: any existing row blocks creating a
|
||||
// new one. A stale "failed" row is requeued in place instead,
|
||||
// so retries don't multiply rows. "rejected" stays rejected.
|
||||
match PendingReview::latest_for_path(&ctx.pool, &input_path_str).await {
|
||||
Ok(None) => {}
|
||||
Ok(Some((id, status, updated_at))) => {
|
||||
if status == "failed" {
|
||||
let stale = chrono::DateTime::parse_from_rfc3339(&updated_at)
|
||||
.map(|t| {
|
||||
chrono::Utc::now().signed_duration_since(t).num_seconds()
|
||||
>= FAILED_RETRY_COOLDOWN_SECS
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if stale {
|
||||
match PendingReview::requeue_by_ids(&ctx.db, &[id]).await {
|
||||
Ok(()) => requeued += 1,
|
||||
Err(e) => log.warn(&format!(
|
||||
"Failed to requeue review {id} for {input_path_str}: {e}"
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
skipped_existing += 1;
|
||||
}
|
||||
} else {
|
||||
skipped_existing += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log.warn(&format!(
|
||||
"Error checking existing review for {}: {e}",
|
||||
@@ -215,8 +256,8 @@ impl Job for InboxDiscoverJob {
|
||||
}
|
||||
|
||||
log.info(&format!(
|
||||
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
|
||||
discovered, skipped_hash, skipped_existing
|
||||
"Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
|
||||
discovered, requeued, skipped_hash, skipped_existing
|
||||
));
|
||||
crate::metrics::record_agent_discover_files(
|
||||
audio_files.len() as u64,
|
||||
@@ -227,7 +268,7 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
// Trigger inbox_process in background if new files were discovered
|
||||
// and no orchestrator is already running
|
||||
if discovered > 0 {
|
||||
if discovered + requeued > 0 {
|
||||
if crate::jobs::inbox_process::is_orchestrator_running() {
|
||||
log.info(
|
||||
"New files discovered but inbox_process already running, it will pick them up",
|
||||
@@ -299,3 +340,75 @@ pub fn is_audio_file(name: &str) -> bool {
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
fn is_junk_file(name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
// macOS AppleDouble sidecars ("._track.mp3") and well-known junk names
|
||||
if lower.starts_with("._") || JUNK_FILENAMES.contains(&lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
let ext = lower.rsplit('.').next().unwrap_or("");
|
||||
JUNK_EXTENSIONS.contains(&ext)
|
||||
}
|
||||
|
||||
/// Remove leftover sidecar files (covers, playlists, rip logs) from inbox
|
||||
/// subtrees that no longer contain any audio, then prune emptied directories.
|
||||
///
|
||||
/// Junk younger than `min_age` is kept in case an upload is still in
|
||||
/// progress, and unknown file types are never touched. Returns `true` when
|
||||
/// `dir` still contains something worth keeping (so the caller must not
|
||||
/// remove it).
|
||||
async fn cleanup_inbox_junk(dir: &Path, min_age: std::time::Duration) -> bool {
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return true,
|
||||
};
|
||||
|
||||
let mut has_audio = false;
|
||||
let mut keep_other = false;
|
||||
let mut junk: Vec<PathBuf> = Vec::new();
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let ft = match entry.file_type().await {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => {
|
||||
keep_other = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if ft.is_dir() {
|
||||
if Box::pin(cleanup_inbox_junk(&entry.path(), min_age)).await {
|
||||
keep_other = true;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_dir(&entry.path()).await;
|
||||
}
|
||||
} else if !name.starts_with('.') && is_audio_file(&name) {
|
||||
// dotfiles are invisible to discovery, so they don't count as audio
|
||||
has_audio = true;
|
||||
} else if is_junk_file(&name) {
|
||||
junk.push(entry.path());
|
||||
} else {
|
||||
keep_other = true;
|
||||
}
|
||||
}
|
||||
|
||||
if has_audio {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut junk_left = false;
|
||||
for path in junk {
|
||||
let old_enough = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.is_some_and(|age| age >= min_age);
|
||||
if !old_enough || tokio::fs::remove_file(&path).await.is_err() {
|
||||
junk_left = true;
|
||||
}
|
||||
}
|
||||
|
||||
keep_other || junk_left
|
||||
}
|
||||
|
||||
+66
-14
@@ -12,6 +12,11 @@ static ORCHESTRATOR_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
||||
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
||||
|
||||
/// Maximum number of files sent to the LLM in a single batch call.
|
||||
/// Folders with more files are processed in chunks of this size, otherwise
|
||||
/// the model's completion window overflows and the JSON response is cut off.
|
||||
const MAX_LLM_BATCH_FILES: usize = 20;
|
||||
|
||||
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
||||
pub fn is_orchestrator_running() -> bool {
|
||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||
@@ -214,14 +219,25 @@ impl Job for InboxProcessJob {
|
||||
folder_rel, file_count,
|
||||
));
|
||||
|
||||
let (ok, fail) =
|
||||
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
|
||||
.await;
|
||||
// Large folders are split into chunks: a single LLM call for
|
||||
// 100+ files overflows the completion window and the whole
|
||||
// batch fails with a truncated-JSON parse error.
|
||||
for chunk in reviews.chunks(MAX_LLM_BATCH_FILES) {
|
||||
let (ok, fail) = process_folder_batch(
|
||||
&ctx.db,
|
||||
&config,
|
||||
&ctx.pool,
|
||||
&folder_rel,
|
||||
chunk.to_vec(),
|
||||
log,
|
||||
)
|
||||
.await;
|
||||
|
||||
total_ok += ok;
|
||||
total_fail += fail;
|
||||
total_ok += ok;
|
||||
total_fail += fail;
|
||||
}
|
||||
log.info(&format!(
|
||||
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
|
||||
"Folder done. Total so far: {total_ok} ok, {total_fail} err"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -344,6 +360,7 @@ async fn process_folder_batch(
|
||||
log.info("Phase 1: extracting metadata...");
|
||||
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
||||
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
||||
let mut merged_count = 0u64;
|
||||
|
||||
for mut review in reviews {
|
||||
let stored_input_path = review.input_path_str().to_owned();
|
||||
@@ -355,9 +372,6 @@ async fn process_folder_batch(
|
||||
.unwrap_or("unknown")
|
||||
.to_owned();
|
||||
|
||||
// Set status → processing
|
||||
let _ = review.set_processing(db).await;
|
||||
|
||||
// Parse context_json
|
||||
let mut context: serde_json::Value = review
|
||||
.context_json
|
||||
@@ -365,6 +379,42 @@ async fn process_folder_batch(
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve duplicates and missing sources before any expensive work.
|
||||
let sha = context
|
||||
.get("sha256")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let file_exists = file_path.exists();
|
||||
if !sha.is_empty()
|
||||
&& crate::agent::rag::file_hash_exists(pool, &sha)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Identical content is already in the library — drop the inbox
|
||||
// copy (same as mover::Merged) and close the review.
|
||||
if file_exists {
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
}
|
||||
let _ = PendingReview::delete_by_ids(db, &[review.id_val()]).await;
|
||||
log.info(&format!(
|
||||
"{filename}: content already in library (sha256 match) — merged duplicate"
|
||||
));
|
||||
crate::metrics::record_agent_file_processed("ok", "merged_duplicate");
|
||||
merged_count += 1;
|
||||
continue;
|
||||
}
|
||||
if !file_exists {
|
||||
let msg = format!("{filename}: source file missing: {stored_input_path}");
|
||||
log.error(&msg);
|
||||
let _ = review.set_failed(db, &msg).await;
|
||||
failed_reviews.push(review);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set status → processing
|
||||
let _ = review.set_processing(db).await;
|
||||
|
||||
// Extract metadata (with 60s timeout)
|
||||
let path_for_meta = file_path.to_path_buf();
|
||||
let metadata_start = std::time::Instant::now();
|
||||
@@ -444,15 +494,16 @@ async fn process_folder_batch(
|
||||
}
|
||||
|
||||
log.info(&format!(
|
||||
"Phase 1 done: {} prepared, {} failed metadata",
|
||||
"Phase 1 done: {} prepared, {} merged duplicates, {} failed",
|
||||
prepared.len(),
|
||||
merged_count,
|
||||
failed_reviews.len(),
|
||||
));
|
||||
|
||||
if prepared.is_empty() {
|
||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
||||
return (0, failed_reviews.len() as u64);
|
||||
return (merged_count, failed_reviews.len() as u64);
|
||||
}
|
||||
|
||||
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
||||
@@ -648,16 +699,17 @@ async fn process_folder_batch(
|
||||
let err_msg = format!("Batch LLM call failed: {e}");
|
||||
log.error(&err_msg);
|
||||
// Mark all files as failed
|
||||
let prepared_count = prepared.len() as u64;
|
||||
for mut p in prepared {
|
||||
let _ = p.review.set_failed(db, &err_msg).await;
|
||||
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||
}
|
||||
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
||||
let total_fail_count = failed_reviews.len() as u64 + prepared_count;
|
||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||
let _ = run
|
||||
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
||||
.await;
|
||||
return (0, total_fail_count);
|
||||
return (merged_count, total_fail_count);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -681,7 +733,7 @@ async fn process_folder_batch(
|
||||
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
||||
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
||||
|
||||
let mut ok_count = 0u64;
|
||||
let mut ok_count = merged_count;
|
||||
let mut fail_count = failed_reviews.len() as u64;
|
||||
|
||||
for mut p in prepared {
|
||||
|
||||
@@ -3,6 +3,7 @@ mod agent;
|
||||
mod api;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod federation;
|
||||
mod i18n;
|
||||
mod jobs;
|
||||
mod lastfm;
|
||||
@@ -559,6 +560,13 @@ impl Project for FuruProject {
|
||||
.await;
|
||||
});
|
||||
|
||||
// Join the federation at boot when it was left enabled (the settings
|
||||
// live in the config KV table; changes apply live from the admin).
|
||||
let fed_config = Arc::clone(&self.app_config);
|
||||
tokio::spawn(async move {
|
||||
federation::handle().boot(&fed_config).await;
|
||||
});
|
||||
|
||||
apps.register(cot::session::db::SessionApp::new());
|
||||
apps.register_with_views(
|
||||
FuruApp {
|
||||
|
||||
@@ -883,6 +883,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
|
||||
"/api/player/lastfm/now-playing",
|
||||
"/api/player/lastfm/scrobble",
|
||||
"/api/player/agent-queue",
|
||||
"/api/player/offline/manifest",
|
||||
"/api/player/torrents",
|
||||
"/api/player/torrents/session/{id}",
|
||||
"/api/player/torrents/preview",
|
||||
|
||||
@@ -325,6 +325,45 @@ pub(super) struct UserProfile {
|
||||
pub(super) stats: UserStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct OfflineManifestResponse {
|
||||
pub(super) generated_at: String,
|
||||
pub(super) tracks: Vec<OfflineTrackManifestItem>,
|
||||
pub(super) playlists: Vec<OfflinePlaylistManifestItem>,
|
||||
pub(super) liked_track_ids: Vec<i64>,
|
||||
pub(super) followed_artist_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct OfflineTrackManifestItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) stream_url: String,
|
||||
pub(super) audio_file_id: i64,
|
||||
pub(super) audio_hash: String,
|
||||
pub(super) audio_size_bytes: i64,
|
||||
pub(super) audio_mime_type: String,
|
||||
pub(super) audio_updated_at: String,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) cover_hash: Option<String>,
|
||||
pub(super) cover_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct OfflinePlaylistManifestItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) is_own: bool,
|
||||
pub(super) owner_name: Option<String>,
|
||||
pub(super) is_public: bool,
|
||||
pub(super) is_saved: bool,
|
||||
pub(super) kind: String,
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct LastfmStatus {
|
||||
pub(super) configured: bool,
|
||||
|
||||
+245
-6
@@ -1048,6 +1048,192 @@ async fn me_handler(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/player/offline/manifest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn offline_manifest_handler(
|
||||
auth_ctx: auth::AuthContext,
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||
};
|
||||
|
||||
let generated_at = now_iso_string();
|
||||
|
||||
let track_rows = sqlx::query_as::<_, OfflineTrackManifestRow>(
|
||||
r#"SELECT t.id,
|
||||
GREATEST(
|
||||
t.updated_at::text,
|
||||
r.updated_at::text,
|
||||
mf.created_at::text,
|
||||
COALESCE(cover_mf.created_at::text, ''),
|
||||
COALESCE((
|
||||
SELECT MAX(a.updated_at::text)
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id
|
||||
), ''),
|
||||
COALESCE((
|
||||
SELECT MAX(a.updated_at::text)
|
||||
FROM furumusic__release_artist ra
|
||||
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||
WHERE ra.release_id = r.id
|
||||
), '')
|
||||
) AS updated_at,
|
||||
mf.id AS audio_file_id,
|
||||
mf.sha256_hash::text AS audio_hash,
|
||||
mf.file_size_bytes AS audio_size_bytes,
|
||||
mf.mime_type::text AS audio_mime_type,
|
||||
mf.created_at::text AS audio_updated_at,
|
||||
cover_mf.id AS cover_file_id,
|
||||
cover_mf.sha256_hash::text AS cover_hash,
|
||||
cover_mf.created_at::text AS cover_updated_at
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__media_file cover_mf
|
||||
ON cover_mf.id = COALESCE(t.cover_file_id, r.cover_file_id)
|
||||
WHERE t.is_hidden = false AND r.is_hidden = false
|
||||
ORDER BY t.id"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let liked_track_ids = sqlx::query_scalar::<_, i64>(
|
||||
r#"SELECT ult.track_id
|
||||
FROM furumusic__user_liked_track ult
|
||||
JOIN furumusic__track t ON t.id = ult.track_id AND t.is_hidden = false
|
||||
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||
WHERE ult.user_id = $1
|
||||
ORDER BY ult.created_at DESC"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let likes_updated_at = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT MAX(created_at::text) FROM furumusic__user_liked_track WHERE user_id = $1",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
.unwrap_or_else(|| generated_at.clone());
|
||||
|
||||
let playlist_rows = sqlx::query_as::<_, OfflinePlaylistManifestRow>(
|
||||
r#"SELECT p.id,
|
||||
p.title::text AS title,
|
||||
p.description,
|
||||
p.updated_at::text AS updated_at,
|
||||
(p.owner_id = $1) AS is_own,
|
||||
COALESCE(NULLIF(u.display_name, ''), u.username)::text AS owner_name,
|
||||
p.is_public,
|
||||
EXISTS (
|
||||
SELECT 1 FROM furumusic__saved_playlist sp
|
||||
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
|
||||
) AS is_saved,
|
||||
COALESCE(
|
||||
array_agg(pt.track_id ORDER BY pt.position)
|
||||
FILTER (WHERE t.id IS NOT NULL AND r.id IS NOT NULL),
|
||||
ARRAY[]::bigint[]
|
||||
) AS track_ids
|
||||
FROM furumusic__playlist p
|
||||
JOIN furumusic__user u ON u.id = p.owner_id
|
||||
LEFT JOIN furumusic__playlist_track pt ON pt.playlist_id = p.id
|
||||
LEFT JOIN furumusic__track t ON t.id = pt.track_id AND t.is_hidden = false
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||
WHERE p.owner_id = $1
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM furumusic__saved_playlist sp
|
||||
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
|
||||
)
|
||||
OR p.is_public = true
|
||||
GROUP BY p.id, u.display_name, u.username
|
||||
ORDER BY
|
||||
CASE WHEN p.owner_id = $1 THEN 0 WHEN p.is_public THEN 2 ELSE 1 END,
|
||||
p.title"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let followed_artist_ids = sqlx::query_scalar::<_, i64>(
|
||||
r#"SELECT ufa.artist_id
|
||||
FROM furumusic__user_followed_artist ufa
|
||||
JOIN furumusic__artist a ON a.id = ufa.artist_id AND a.is_hidden = false
|
||||
WHERE ufa.user_id = $1
|
||||
ORDER BY ufa.created_at DESC"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let tracks = track_rows
|
||||
.into_iter()
|
||||
.map(|row| OfflineTrackManifestItem {
|
||||
id: row.id,
|
||||
updated_at: row.updated_at,
|
||||
stream_url: format!("/api/player/stream/{}", row.id),
|
||||
audio_file_id: row.audio_file_id,
|
||||
audio_hash: row.audio_hash,
|
||||
audio_size_bytes: row.audio_size_bytes,
|
||||
audio_mime_type: row.audio_mime_type,
|
||||
audio_updated_at: row.audio_updated_at,
|
||||
cover_file_id: row.cover_file_id,
|
||||
cover_url: cover_variant_url(row.cover_file_id, "medium"),
|
||||
cover_hash: row.cover_hash,
|
||||
cover_updated_at: row.cover_updated_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut playlists = vec![OfflinePlaylistManifestItem {
|
||||
id: -1,
|
||||
title: "Likes".to_string(),
|
||||
description: None,
|
||||
updated_at: likes_updated_at,
|
||||
is_own: true,
|
||||
owner_name: None,
|
||||
is_public: false,
|
||||
is_saved: false,
|
||||
kind: "likes".to_string(),
|
||||
track_ids: liked_track_ids.clone(),
|
||||
}];
|
||||
|
||||
playlists.extend(
|
||||
playlist_rows
|
||||
.into_iter()
|
||||
.map(|row| OfflinePlaylistManifestItem {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
updated_at: row.updated_at,
|
||||
is_own: row.is_own,
|
||||
owner_name: Some(row.owner_name),
|
||||
is_public: row.is_public,
|
||||
is_saved: row.is_saved,
|
||||
kind: "user".to_string(),
|
||||
track_ids: row.track_ids,
|
||||
}),
|
||||
);
|
||||
|
||||
Json(OfflineManifestResponse {
|
||||
generated_at,
|
||||
tracks,
|
||||
playlists,
|
||||
liked_track_ids,
|
||||
followed_artist_ids,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last.fm account + scrobbling
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3855,7 +4041,11 @@ async fn stream_handler(
|
||||
|
||||
// Look up track → audio_file_id → MediaFile
|
||||
let media = sqlx::query_as::<_, MediaFileRow>(
|
||||
r#"SELECT mf.file_path, mf.mime_type::text as mime_type, mf.file_size_bytes
|
||||
r#"SELECT mf.file_path,
|
||||
mf.mime_type::text as mime_type,
|
||||
mf.file_size_bytes,
|
||||
mf.sha256_hash::text AS sha256_hash,
|
||||
mf.created_at::text AS created_at
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
WHERE t.id = $1"#,
|
||||
@@ -3880,6 +4070,7 @@ async fn stream_handler(
|
||||
}
|
||||
|
||||
let file_size = media.file_size_bytes as u64;
|
||||
let etag = media_etag("audio", track_id, &media.sha256_hash, None);
|
||||
|
||||
// Parse Range header
|
||||
let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok());
|
||||
@@ -3899,6 +4090,9 @@ async fn stream_handler(
|
||||
.header(ACCEPT_RANGES, "bytes")
|
||||
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
|
||||
.header(CONTENT_LENGTH, chunk_size.to_string())
|
||||
.header("ETag", etag.as_str())
|
||||
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
|
||||
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
|
||||
.body(Body::fixed(data))
|
||||
.expect("valid response");
|
||||
|
||||
@@ -3917,6 +4111,9 @@ async fn stream_handler(
|
||||
.header(CONTENT_TYPE, media.mime_type.as_str())
|
||||
.header(ACCEPT_RANGES, "bytes")
|
||||
.header(CONTENT_LENGTH, file_size.to_string())
|
||||
.header("ETag", etag.as_str())
|
||||
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
|
||||
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
|
||||
.body(Body::fixed(data))
|
||||
.expect("valid response");
|
||||
|
||||
@@ -4167,7 +4364,12 @@ async fn cover_response(
|
||||
};
|
||||
|
||||
let media = sqlx::query_as::<_, MediaFileRow>(
|
||||
"SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1",
|
||||
r#"SELECT file_path,
|
||||
mime_type::text as mime_type,
|
||||
file_size_bytes,
|
||||
sha256_hash::text AS sha256_hash,
|
||||
created_at::text AS created_at
|
||||
FROM furumusic__media_file WHERE id = $1"#,
|
||||
)
|
||||
.bind(media_file_id)
|
||||
.fetch_optional(pool)
|
||||
@@ -4185,26 +4387,30 @@ async fn cover_response(
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk"));
|
||||
}
|
||||
|
||||
let (response_path, content_type) = variant_name
|
||||
let (response_path, content_type, etag_variant) = variant_name
|
||||
.and_then(crate::agent::cover_variants::variant_by_name)
|
||||
.map(|variant| {
|
||||
let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant);
|
||||
if variant_path.exists() {
|
||||
(variant_path, "image/jpeg")
|
||||
(variant_path, "image/jpeg", Some(variant.name))
|
||||
} else {
|
||||
(full_path.clone(), media.mime_type.as_str())
|
||||
(full_path.clone(), media.mime_type.as_str(), None)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str()));
|
||||
.unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str(), None));
|
||||
|
||||
let data = tokio::fs::read(&response_path)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let etag = media_etag("cover", media_file_id, &media.sha256_hash, etag_variant);
|
||||
|
||||
let response = cot::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, content_type)
|
||||
.header(CONTENT_LENGTH, data.len().to_string())
|
||||
.header("ETag", etag.as_str())
|
||||
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
|
||||
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
|
||||
.header("Cache-Control", "public, max-age=86400")
|
||||
.body(Body::fixed(data))
|
||||
.expect("valid response");
|
||||
@@ -4212,6 +4418,13 @@ async fn cover_response(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn media_etag(kind: &str, id: i64, sha256_hash: &str, variant: Option<&str>) -> String {
|
||||
match variant {
|
||||
Some(variant) => format!("\"{kind}-{id}-{variant}-{sha256_hash}\""),
|
||||
None => format!("\"{kind}-{id}-{sha256_hash}\""),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player devices
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -6271,6 +6484,32 @@ impl App for PlayerApp {
|
||||
},
|
||||
"player_me",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/offline/manifest",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(
|
||||
move |auth_ctx: auth::AuthContext, session: Session, db: Database| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("player pool")
|
||||
})
|
||||
.await;
|
||||
offline_manifest_handler(auth_ctx, session, db, pg_pool).await
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
"player_offline_manifest",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/lastfm/status",
|
||||
get({
|
||||
|
||||
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
|
||||
pub(super) file_path: String,
|
||||
pub(super) mime_type: String,
|
||||
pub(super) file_size_bytes: i64,
|
||||
pub(super) sha256_hash: String,
|
||||
pub(super) created_at: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -287,3 +289,30 @@ pub(super) struct ReleaseInfoRow {
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct OfflineTrackManifestRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) audio_file_id: i64,
|
||||
pub(super) audio_hash: String,
|
||||
pub(super) audio_size_bytes: i64,
|
||||
pub(super) audio_mime_type: String,
|
||||
pub(super) audio_updated_at: String,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) cover_hash: Option<String>,
|
||||
pub(super) cover_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct OfflinePlaylistManifestRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) is_own: bool,
|
||||
pub(super) owner_name: String,
|
||||
pub(super) is_public: bool,
|
||||
pub(super) is_saved: bool,
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
+18
-8
@@ -496,14 +496,24 @@ impl PendingReview {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> {
|
||||
let all = Self::objects().all(db).await?;
|
||||
let exists = all.iter().any(|r| {
|
||||
let s = r.status.as_str();
|
||||
// "rejected" and "failed" reviews should not block re-discovery
|
||||
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path)
|
||||
});
|
||||
Ok(exists)
|
||||
/// Latest review row for an inbox path: `(id, status, updated_at)`.
|
||||
///
|
||||
/// Used by inbox_discover to decide whether a file needs a new review,
|
||||
/// a requeue of its existing row, or nothing at all — without creating
|
||||
/// a fresh row per retry.
|
||||
pub async fn latest_for_path(
|
||||
pool: &sqlx::PgPool,
|
||||
path: &str,
|
||||
) -> anyhow::Result<Option<(i64, String, String)>> {
|
||||
let row: Option<(i64, String, String)> = sqlx::query_as(
|
||||
"SELECT id, status::text, updated_at::text \
|
||||
FROM furumusic__pending_review WHERE input_path = $1 \
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(path)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Mark all "processing" reviews as "failed" — called at scheduler
|
||||
|
||||
+137
-2
@@ -1613,7 +1613,7 @@ tbody tr:hover {
|
||||
</button>
|
||||
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
||||
<i data-lucide="list-checks"></i>
|
||||
Select filter
|
||||
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
||||
</button>
|
||||
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
||||
<i data-lucide="x"></i>
|
||||
@@ -2265,6 +2265,73 @@ tbody tr:hover {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Federation</strong>
|
||||
<span>Publish this library into the furumi P2P network</span>
|
||||
</div>
|
||||
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle">
|
||||
<label>
|
||||
<span>Federation enabled</span>
|
||||
<span class="source-pill" :class="sourceClass('federation_enabled')" x-text="settingSource('federation_enabled')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Network ID (shared secret)</span>
|
||||
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
|
||||
<div class="setting-help">Every peer using the same id finds the others automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="probe-body" x-show="federationStatus.node">
|
||||
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
||||
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
|
||||
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
|
||||
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
|
||||
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||
</div>
|
||||
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
||||
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
||||
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
||||
<i data-lucide="upload-cloud"></i>
|
||||
Publish now
|
||||
</button>
|
||||
<button class="btn" type="button" @click="fedShowTicket()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
||||
<i data-lucide="ticket"></i>
|
||||
Show ticket
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-field settings-wide" x-show="federationTicket" style="margin-top:10px">
|
||||
<label>Connection ticket (share with a peer)</label>
|
||||
<textarea readonly rows="3" style="width:100%; font-family:monospace; font-size:11px" x-text="federationTicket"></textarea>
|
||||
</div>
|
||||
<div class="setting-field settings-wide" x-show="federationStatus.node && federationStatus.node.running" style="margin-top:10px">
|
||||
<label>Connect to a peer by ticket</label>
|
||||
<div style="display:flex; gap:8px">
|
||||
<input x-model="fedConnectTicket" placeholder="fnet..." style="flex:1" autocomplete="off" />
|
||||
<button class="btn" type="button" @click="fedConnect()" :disabled="federationLoading || !fedConnectTicket.trim()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
@@ -2832,10 +2899,16 @@ function adminV2() {
|
||||
agent_llm_auth: '',
|
||||
agent_confidence_threshold: '',
|
||||
agent_context_limit: '',
|
||||
agent_concurrency: ''
|
||||
agent_concurrency: '',
|
||||
federation_enabled: false,
|
||||
federation_network_id: ''
|
||||
},
|
||||
settingsProbe: { status: 'idle', ok: false },
|
||||
settingsProbeLoading: false,
|
||||
federationStatus: {},
|
||||
federationLoading: false,
|
||||
federationTicket: '',
|
||||
fedConnectTicket: '',
|
||||
settingsSaving: false,
|
||||
routeReady: false,
|
||||
poller: null,
|
||||
@@ -3115,6 +3188,7 @@ function adminV2() {
|
||||
body: JSON.stringify(this.settingsDraft)
|
||||
});
|
||||
await this.loadSettings(false);
|
||||
await this.loadFederation(false);
|
||||
this.showToast('Settings saved');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -3128,11 +3202,72 @@ function adminV2() {
|
||||
this.activeView = 'settings';
|
||||
this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await this.loadFederation(false);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
},
|
||||
|
||||
async loadFederation(showErrors = true) {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
this.federationStatus = await this.request(`${this.apiBase}/federation`);
|
||||
} catch (error) {
|
||||
if (showErrors) this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async fedSyncNow() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
this.federationStatus = await this.request(`${this.apiBase}/federation/sync`, { method: 'POST', body: '{}' });
|
||||
this.showToast('Library published to the federation');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async fedShowTicket() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/federation/ticket`);
|
||||
this.federationTicket = data.ticket || '';
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async fedConnect() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/federation/connect`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticket: this.fedConnectTicket.trim() })
|
||||
});
|
||||
this.fedConnectTicket = '';
|
||||
this.showToast(`Connected to ${(data.connected || '').slice(0, 12)}…`);
|
||||
await this.loadFederation(false);
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
fedShort(id) {
|
||||
return id ? `${id.slice(0, 12)}…` : '-';
|
||||
},
|
||||
|
||||
async loadSettingsProbe(showErrors = true) {
|
||||
this.settingsProbeLoading = true;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user