Compare commits

..
11 Commits
Author SHA1 Message Date
Ultradesu 4381750c6e Extend DHT scheme
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-07-20 16:29:21 +03:00
Ultradesu 3485f643f4 Fixed DHT republish-after-ttl
Build and Publish / Build and Publish Docker Image (push) Successful in 4m58s
2026-07-20 14:19:11 +03:00
Ultradesu bca0f5e2f0 added psql support
Build and Publish / Build and Publish Docker Image (push) Successful in 9m24s
2026-07-20 02:03:44 +03:00
Ultradesu 53b2ff29f8 Moved DHT state in psql 2026-07-20 01:59:45 +03:00
Ultradesu c349512fb0 fixed gitignore 2026-07-17 14:01:13 +03:00
Ultradesu 0615356785 Added federation
Build and Publish / Build and Publish Docker Image (push) Successful in 4m55s
2026-07-17 13:30:55 +03:00
Ultradesu 184371afca Added offline mode support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m46s
2026-07-15 18:13:40 +03:00
Ultradesu 716da908c9 Fixed inbox_process task
Build and Publish / Build and Publish Docker Image (push) Successful in 5m34s
2026-07-08 15:22:21 +03:00
Ultradesu 0c120c0868 Added support of TUI player
Build and Publish / Build and Publish Docker Image (push) Successful in 2m38s
2026-06-10 23:20:18 +01:00
Ultradesu d9d0fbb7d1 Added cli client SSO login support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
2026-06-10 13:34:38 +01:00
Ultradesu 71d6556ba8 Improved artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-09 17:18:55 +01:00
21 changed files with 5084 additions and 664 deletions
+1
View File
@@ -2,3 +2,4 @@
/nul /nul
/.claude /.claude
/media /media
/federation
Generated
+2535 -585
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,11 +1,14 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.4.4" version = "0.6.3-fd"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
[dependencies] [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"] } schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
openidconnect = "4.0" openidconnect = "4.0"
@@ -28,5 +31,9 @@ anyhow = "1.0"
tokio-cron-scheduler = "0.15" tokio-cron-scheduler = "0.15"
croner = "3" croner = "3"
async-trait = "0.1" async-trait = "0.1"
postcard = { version = "1", features = ["alloc"] }
uuid = "1" uuid = "1"
librqbit = { version = "8.1.1", features = ["disable-upload"] } 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" }
+32
View File
@@ -415,6 +415,38 @@ impl App for AdminApp {
}), }),
"admin_v2_settings_probe", "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( Route::with_handler_and_name(
"/v2/api/jobs/{name}/toggle", "/v2/api/jobs/{name}/toggle",
cot::router::method::post({ cot::router::method::post({
+88
View File
@@ -448,6 +448,10 @@ struct AdminSettingsValues {
agent_confidence_threshold: String, agent_confidence_threshold: String,
agent_context_limit: String, agent_context_limit: String,
agent_concurrency: String, agent_concurrency: String,
#[serde(default)]
federation_enabled: bool,
#[serde(default)]
federation_network_id: String,
} }
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -472,6 +476,8 @@ struct AdminSettingsSources {
agent_confidence_threshold: &'static str, agent_confidence_threshold: &'static str,
agent_context_limit: &'static str, agent_context_limit: &'static str,
agent_concurrency: &'static str, agent_concurrency: &'static str,
federation_enabled: &'static str,
federation_network_id: &'static str,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -496,6 +502,10 @@ pub(super) struct UpdateSettingsRequest {
agent_confidence_threshold: String, agent_confidence_threshold: String,
agent_context_limit: String, agent_context_limit: String,
agent_concurrency: String, agent_concurrency: String,
#[serde(default)]
federation_enabled: bool,
#[serde(default)]
federation_network_id: String,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
@@ -978,6 +988,11 @@ pub async fn update_settings(
"agent_concurrency", "agent_concurrency",
body.agent_concurrency.trim().to_string(), 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 { for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_string(), value); let mut entry = ConfigEntry::new(key.to_string(), value);
@@ -986,9 +1001,78 @@ pub async fn update_settings(
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .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() 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( pub async fn settings_probe(
session: Session, session: Session,
db: Database, db: Database,
@@ -1057,6 +1141,8 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
agent_confidence_threshold: config.agent_confidence_threshold.to_string(), agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
agent_context_limit: config.agent_context_limit.to_string(), agent_context_limit: config.agent_context_limit.to_string(),
agent_concurrency: config.agent_concurrency.to_string(), agent_concurrency: config.agent_concurrency.to_string(),
federation_enabled: config.federation_enabled,
federation_network_id: config.federation_network_id,
}, },
sources: AdminSettingsSources { sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(), 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_confidence_threshold: sources.agent_confidence_threshold.code(),
agent_context_limit: sources.agent_context_limit.code(), agent_context_limit: sources.agent_context_limit.code(),
agent_concurrency: sources.agent_concurrency.code(), agent_concurrency: sources.agent_concurrency.code(),
federation_enabled: sources.federation_enabled.code(),
federation_network_id: sources.federation_network_id.code(),
}, },
} }
} }
+16
View File
@@ -135,6 +135,8 @@ pub struct ConfigSources {
pub agent_concurrency: ConfigSource, pub agent_concurrency: ConfigSource,
pub lastfm_api_key: ConfigSource, pub lastfm_api_key: ConfigSource,
pub lastfm_shared_secret: ConfigSource, pub lastfm_shared_secret: ConfigSource,
pub federation_enabled: ConfigSource,
pub federation_network_id: ConfigSource,
} }
impl Default for ConfigSources { impl Default for ConfigSources {
@@ -162,6 +164,8 @@ impl Default for ConfigSources {
agent_concurrency: ConfigSource::Default, agent_concurrency: ConfigSource::Default,
lastfm_api_key: ConfigSource::Default, lastfm_api_key: ConfigSource::Default,
lastfm_shared_secret: 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, pub lastfm_api_key: String,
/// Last.fm shared secret for authenticated scrobbling calls. /// Last.fm shared secret for authenticated scrobbling calls.
pub lastfm_shared_secret: String, 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 { impl Default for AppConfig {
@@ -297,6 +307,8 @@ impl Default for AppConfig {
agent_concurrency: 2, agent_concurrency: 2,
lastfm_api_key: String::new(), lastfm_api_key: String::new(),
lastfm_shared_secret: String::new(), lastfm_shared_secret: String::new(),
federation_enabled: false,
federation_network_id: String::new(),
} }
} }
} }
@@ -325,6 +337,8 @@ impl_env_overrides!(
agent_concurrency, agent_concurrency,
lastfm_api_key, lastfm_api_key,
lastfm_shared_secret, lastfm_shared_secret,
federation_enabled,
federation_network_id,
); );
impl AppConfig { impl AppConfig {
@@ -452,6 +466,8 @@ impl AppConfig {
apply_db_field!(agent_concurrency); apply_db_field!(agent_concurrency);
apply_db_field!(lastfm_api_key); apply_db_field!(lastfm_api_key);
apply_db_field!(lastfm_shared_secret); apply_db_field!(lastfm_shared_secret);
apply_db_field!(federation_enabled);
apply_db_field!(federation_network_id);
} }
} }
+523
View File
@@ -0,0 +1,523 @@
//! 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::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<()>>,
}
pub struct Federation {
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
data_dir: PathBuf,
database_url: std::sync::Mutex<String>,
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()),
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();
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, 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) -> 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,
});
}
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,
});
}
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
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 &tracks {
let id: i64 = row.get(0);
let duration: f64 = row.get(3);
specs.push(ItemSpec {
local_key: format!("track:{id}"),
kind: ItemKind::Track,
name: row.get(1),
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
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),
});
}
Ok(specs)
}
/// 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 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");
}
+685
View File
@@ -0,0 +1,685 @@
//! 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>,
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 id, title, track_number, disc_number, duration_seconds
FROM furumusic__track
WHERE release_id = $1 AND is_hidden = false
ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title",
)
.bind(release_id)
.fetch_all(pool)
.await?;
releases.push(CatalogRelease {
title: release_row.get(1),
release_type: release_row.get(2),
year: release_row.get(3),
tracks: track_rows
.into_iter()
.map(|row| {
let track_id: i64 = row.get(0);
let duration: f64 = row.get(4);
CatalogTrack {
title: row.get(1),
track_number: row.get(2),
disc_number: row.get(3),
duration_seconds: (duration > 0.0).then_some(duration),
item_id: item_id_of(own, track_id),
}
})
.collect(),
});
}
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))))
}
+316
View File
@@ -0,0 +1,316 @@
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
)",
];
#[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())
}
+1
View File
@@ -373,6 +373,7 @@ translations! {
player_repeat: "Repeat" , "Повтор"; player_repeat: "Repeat" , "Повтор";
player_volume: "Volume" , "Громкость"; player_volume: "Volume" , "Громкость";
player_appears_on: "Appears on" , "Участвует в"; player_appears_on: "Appears on" , "Участвует в";
player_top_tracks: "Popular tracks" , "Популярные треки";
player_albums: "Albums" , "Альбомы"; player_albums: "Albums" , "Альбомы";
player_eps: "EPs" , "EP"; player_eps: "EPs" , "EP";
player_singles: "Singles" , "Синглы"; player_singles: "Singles" , "Синглы";
+121 -8
View File
@@ -12,6 +12,21 @@ const AUDIO_EXTENSIONS: &[&str] = &[
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif", "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; pub struct InboxDiscoverJob;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -76,6 +91,10 @@ impl Job for InboxDiscoverJob {
let mut audio_files = Vec::new(); let mut audio_files = Vec::new();
collect_audio_files(inbox, &mut audio_files).await?; 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())); log.info(&format!("Found {} audio files in inbox", audio_files.len()));
if audio_files.is_empty() { if audio_files.is_empty() {
return Ok(()); return Ok(());
@@ -87,6 +106,7 @@ impl Job for InboxDiscoverJob {
let mut discovered = 0u64; let mut discovered = 0u64;
let mut skipped_hash = 0u64; let mut skipped_hash = 0u64;
let mut skipped_existing = 0u64; let mut skipped_existing = 0u64;
let mut requeued = 0u64;
for (_folder, files) in &groups { for (_folder, files) in &groups {
for file_path in files { 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) crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
.unwrap_or_else(|| file_path.to_string_lossy().to_string()); .unwrap_or_else(|| file_path.to_string_lossy().to_string());
// Skip if a PendingReview already exists for this path // One review row per path: any existing row blocks creating a
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await { // new one. A stale "failed" row is requeued in place instead,
Ok(true) => { // so retries don't multiply rows. "rejected" stays rejected.
skipped_existing += 1; 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; continue;
} }
Ok(false) => {}
Err(e) => { Err(e) => {
log.warn(&format!( log.warn(&format!(
"Error checking existing review for {}: {e}", "Error checking existing review for {}: {e}",
@@ -215,8 +256,8 @@ impl Job for InboxDiscoverJob {
} }
log.info(&format!( log.info(&format!(
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)", "Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
discovered, skipped_hash, skipped_existing discovered, requeued, skipped_hash, skipped_existing
)); ));
crate::metrics::record_agent_discover_files( crate::metrics::record_agent_discover_files(
audio_files.len() as u64, audio_files.len() as u64,
@@ -227,7 +268,7 @@ impl Job for InboxDiscoverJob {
// Trigger inbox_process in background if new files were discovered // Trigger inbox_process in background if new files were discovered
// and no orchestrator is already running // and no orchestrator is already running
if discovered > 0 { if discovered + requeued > 0 {
if crate::jobs::inbox_process::is_orchestrator_running() { if crate::jobs::inbox_process::is_orchestrator_running() {
log.info( log.info(
"New files discovered but inbox_process already running, it will pick them up", "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(); let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
AUDIO_EXTENSIONS.contains(&ext.as_str()) 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
View File
@@ -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. /// 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 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). /// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
pub fn is_orchestrator_running() -> bool { pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst) ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
@@ -214,14 +219,25 @@ impl Job for InboxProcessJob {
folder_rel, file_count, folder_rel, file_count,
)); ));
let (ok, fail) = // Large folders are split into chunks: a single LLM call for
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log) // 100+ files overflows the completion window and the whole
.await; // 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_ok += ok;
total_fail += fail; total_fail += fail;
}
log.info(&format!( 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..."); log.info("Phase 1: extracting metadata...");
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count); let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
let mut failed_reviews: Vec<PendingReview> = Vec::new(); let mut failed_reviews: Vec<PendingReview> = Vec::new();
let mut merged_count = 0u64;
for mut review in reviews { for mut review in reviews {
let stored_input_path = review.input_path_str().to_owned(); let stored_input_path = review.input_path_str().to_owned();
@@ -355,9 +372,6 @@ async fn process_folder_batch(
.unwrap_or("unknown") .unwrap_or("unknown")
.to_owned(); .to_owned();
// Set status → processing
let _ = review.set_processing(db).await;
// Parse context_json // Parse context_json
let mut context: serde_json::Value = review let mut context: serde_json::Value = review
.context_json .context_json
@@ -365,6 +379,42 @@ async fn process_folder_batch(
.and_then(|s| serde_json::from_str(s).ok()) .and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default(); .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) // Extract metadata (with 60s timeout)
let path_for_meta = file_path.to_path_buf(); let path_for_meta = file_path.to_path_buf();
let metadata_start = std::time::Instant::now(); let metadata_start = std::time::Instant::now();
@@ -444,15 +494,16 @@ async fn process_folder_batch(
} }
log.info(&format!( log.info(&format!(
"Phase 1 done: {} prepared, {} failed metadata", "Phase 1 done: {} prepared, {} merged duplicates, {} failed",
prepared.len(), prepared.len(),
merged_count,
failed_reviews.len(), failed_reviews.len(),
)); ));
if prepared.is_empty() { if prepared.is_empty() {
let duration_ms = batch_start.elapsed().as_millis() as i64; let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run.set_completed(db, duration_ms, &log.output()).await; 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) // 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}"); let err_msg = format!("Batch LLM call failed: {e}");
log.error(&err_msg); log.error(&err_msg);
// Mark all files as failed // Mark all files as failed
let prepared_count = prepared.len() as u64;
for mut p in prepared { for mut p in prepared {
let _ = p.review.set_failed(db, &err_msg).await; let _ = p.review.set_failed(db, &err_msg).await;
crate::metrics::record_agent_file_processed("failed", "failed"); 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 duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run let _ = run
.set_failed(db, duration_ms, &log.output(), &err_msg) .set_failed(db, duration_ms, &log.output(), &err_msg)
.await; .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 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 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; let mut fail_count = failed_reviews.len() as u64;
for mut p in prepared { for mut p in prepared {
+8
View File
@@ -3,6 +3,7 @@ mod agent;
mod api; mod api;
mod auth; mod auth;
mod config; mod config;
mod federation;
mod i18n; mod i18n;
mod jobs; mod jobs;
mod lastfm; mod lastfm;
@@ -559,6 +560,13 @@ impl Project for FuruProject {
.await; .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(cot::session::db::SessionApp::new());
apps.register_with_views( apps.register_with_views(
FuruApp { FuruApp {
+1
View File
@@ -883,6 +883,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/api/player/lastfm/now-playing", "/api/player/lastfm/now-playing",
"/api/player/lastfm/scrobble", "/api/player/lastfm/scrobble",
"/api/player/agent-queue", "/api/player/agent-queue",
"/api/player/offline/manifest",
"/api/player/torrents", "/api/player/torrents",
"/api/player/torrents/session/{id}", "/api/player/torrents/session/{id}",
"/api/player/torrents/preview", "/api/player/torrents/preview",
+74 -5
View File
@@ -977,27 +977,54 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") { if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
return Some(value.to_owned()); return Some(value.to_owned());
} }
if is_loopback_http_redirect(&lower) {
return Some(value.to_owned());
}
None None
} }
/// RFC 8252 §7.3: native apps without a custom URL scheme (the CLI client)
/// receive the callback on a loopback listener with an ephemeral port.
fn is_loopback_http_redirect(lower: &str) -> bool {
let Some(rest) = lower.strip_prefix("http://") else {
return false;
};
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
let Some((host, port)) = host_port.rsplit_once(':') else {
return false;
};
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
&& !port.is_empty()
&& port.len() <= 5
&& port.bytes().all(|b| b.is_ascii_digit())
}
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response { fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "code", code); let deep_link = append_query_param(app_redirect_uri, "code", code);
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
return auth::redirect(&deep_link);
}
mobile_deep_link_page( mobile_deep_link_page(
"success", "success",
"Sign-in complete", "Sign-in complete",
"Furumi should open automatically. You can close this window after the app opens.", "Furumi should open automatically. If it doesn't, use the button or copy the code below.",
None, None,
Some(code),
&deep_link, &deep_link,
) )
} }
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "error", error); let deep_link = append_query_param(app_redirect_uri, "error", error);
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
return auth::redirect(&deep_link);
}
mobile_deep_link_page( mobile_deep_link_page(
"error", "error",
"Sign-in failed", "Sign-in failed",
"Furumi should open automatically and show the sign-in error. You can close this window after the app opens.", "Furumi should open automatically and show the sign-in error.",
Some(error), Some(error),
None,
&deep_link, &deep_link,
) )
} }
@@ -1007,6 +1034,7 @@ fn mobile_deep_link_page(
title: &str, title: &str,
message: &str, message: &str,
detail: Option<&str>, detail: Option<&str>,
code: Option<&str>,
deep_link: &str, deep_link: &str,
) -> cot::response::Response { ) -> cot::response::Response {
let state_class = html_escape(state); let state_class = html_escape(state);
@@ -1015,6 +1043,15 @@ fn mobile_deep_link_page(
let detail_html = detail let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value))) .map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.unwrap_or_default(); .unwrap_or_default();
let code_html = code
.map(|value| {
format!(
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
<input class="code" readonly value="{}" onclick="this.select()">"#,
html_escape(value)
)
})
.unwrap_or_default();
let deep_link_html = html_escape(deep_link); let deep_link_html = html_escape(deep_link);
let deep_link_js = let deep_link_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail"); serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
@@ -1095,6 +1132,19 @@ fn mobile_deep_link_page(
font-size: 13px; font-size: 13px;
color: #89847c; color: #89847c;
}} }}
.code {{
width: 100%;
margin-top: 8px;
padding: 10px 12px;
box-sizing: border-box;
border: 1px solid #3a3c42;
border-radius: 8px;
background: #1a1c20;
color: #e8d8a8;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
text-align: center;
}}
</style> </style>
</head> </head>
<body> <body>
@@ -1105,15 +1155,13 @@ fn mobile_deep_link_page(
{detail_html} {detail_html}
<a href="{deep_link_html}">Open Furumi</a> <a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p> <p class="hint">If nothing happens, use the button above.</p>
{code_html}
</main> </main>
<script> <script>
const deepLink = {deep_link_js}; const deepLink = {deep_link_js};
window.setTimeout(() => {{ window.setTimeout(() => {{
window.location.href = deepLink; window.location.href = deepLink;
}}, 100); }}, 100);
window.setTimeout(() => {{
window.close();
}}, 1800);
</script> </script>
</body> </body>
</html>"#, </html>"#,
@@ -1230,4 +1278,25 @@ mod tests {
); );
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none()); assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
} }
#[test]
fn mobile_oidc_redirect_uri_allows_loopback_http() {
assert_eq!(
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
Some("http://127.0.0.1:8753/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
Some("http://localhost:1234/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
Some("http://[::1]:1234/callback")
);
// Non-loopback hosts, missing ports and https stay rejected.
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
}
} }
+39
View File
@@ -325,6 +325,45 @@ pub(super) struct UserProfile {
pub(super) stats: UserStats, 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)] #[derive(Debug, Serialize, JsonSchema)]
pub(super) struct LastfmStatus { pub(super) struct LastfmStatus {
pub(super) configured: bool, pub(super) configured: bool,
+308 -40
View File
@@ -861,6 +861,12 @@ fn native_device_name_from_user_agent(user_agent: Option<&str>) -> Option<String
None => "Furumi MacOS".to_string(), None => "Furumi MacOS".to_string(),
}); });
} }
if product.eq_ignore_ascii_case("FurumiTUI") || product.eq_ignore_ascii_case("furumi-tui") {
return Some(match version.as_deref() {
Some(v) => format!("Furumi TUI {v}"),
None => "Furumi TUI".to_string(),
});
}
} }
None None
} }
@@ -890,6 +896,9 @@ fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str {
if ua.contains("furumimac") { if ua.contains("furumimac") {
return "computer"; return "computer";
} }
if ua.contains("furumitui/") || ua.contains("furumi-tui/") {
return "computer";
}
if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) { if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) {
"phone" "phone"
} else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") { } else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") {
@@ -914,6 +923,22 @@ mod device_tests {
assert_eq!(device_kind_from_user_agent(user_agent), "phone"); assert_eq!(device_kind_from_user_agent(user_agent), "phone");
} }
#[test]
fn detects_furumi_tui_native_client() {
let user_agent = Some("FurumiTUI/0.1.0 macos");
assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0");
assert_eq!(device_kind_from_user_agent(user_agent), "computer");
}
#[test]
fn detects_furumi_tui_http_user_agent_token() {
let user_agent = Some("furumi-tui/0.1.0 (macos)");
assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0");
assert_eq!(device_kind_from_user_agent(user_agent), "computer");
}
#[test] #[test]
fn keeps_browser_fallback_for_generic_android_user_agents() { fn keeps_browser_fallback_for_generic_android_user_agents() {
let user_agent = Some("Mozilla/5.0 Android Mobile"); let user_agent = Some("Mozilla/5.0 Android Mobile");
@@ -1023,6 +1048,192 @@ async fn me_handler(
.into_response() .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 // Last.fm account + scrobbling
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3201,40 +3412,44 @@ async fn artist_detail_handler(
.collect(); .collect();
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>( let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, r#"SELECT * FROM (
t.duration_seconds, t.cover_file_id, SELECT DISTINCT ON (lower(t.title::text))
r.cover_file_id as release_cover_file_id, t.id, t.title::text as title, t.track_number, t.disc_number,
r.id as release_id, t.duration_seconds, t.cover_file_id,
r.title::text as release_title, r.cover_file_id as release_cover_file_id,
r.year as release_year, r.id as release_id,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, r.title::text as release_title,
mf.audio_format, r.year as release_year,
mf.audio_bitrate, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_sample_rate, mf.audio_format,
mf.audio_bit_depth, mf.audio_bitrate,
mf.file_size_bytes, mf.audio_sample_rate,
t.lastfm_listeners, mf.audio_bit_depth,
t.lastfm_playcount, mf.file_size_bytes,
t.lastfm_rating, t.lastfm_listeners,
t.lastfm_updated_at t.lastfm_playcount,
FROM furumusic__track t t.lastfm_rating,
JOIN furumusic__release r ON r.id = t.release_id t.lastfm_updated_at
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id FROM furumusic__track t
WHERE t.is_hidden = false JOIN furumusic__release r ON r.id = t.release_id
AND r.is_hidden = false LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
AND EXISTS ( WHERE t.is_hidden = false
SELECT 1 AND r.is_hidden = false
FROM furumusic__track_artist ta AND EXISTS (
WHERE ta.track_id = t.id SELECT 1
AND ta.artist_id = $1 FROM furumusic__track_artist ta
AND ta.role <> 'featuring' WHERE ta.track_id = t.id
) AND ta.artist_id = $1
ORDER BY COALESCE(t.lastfm_rating, 0) DESC, AND ta.role <> 'featuring'
COALESCE(t.lastfm_playcount, 0) DESC, )
COALESCE(t.lastfm_listeners, 0) DESC, ORDER BY lower(t.title::text), COALESCE(t.lastfm_rating, 0) DESC
r.year DESC NULLS LAST, ) deduped
t.track_number NULLS LAST, ORDER BY COALESCE(lastfm_rating, 0) DESC,
t.id COALESCE(lastfm_playcount, 0) DESC,
COALESCE(lastfm_listeners, 0) DESC,
release_year DESC NULLS LAST,
track_number NULLS LAST,
id
LIMIT 50"#, LIMIT 50"#,
) )
.bind(artist_id) .bind(artist_id)
@@ -3826,7 +4041,11 @@ async fn stream_handler(
// Look up track → audio_file_id → MediaFile // Look up track → audio_file_id → MediaFile
let media = sqlx::query_as::<_, MediaFileRow>( 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 FROM furumusic__track t
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.id = $1"#, WHERE t.id = $1"#,
@@ -3851,6 +4070,7 @@ async fn stream_handler(
} }
let file_size = media.file_size_bytes as u64; let file_size = media.file_size_bytes as u64;
let etag = media_etag("audio", track_id, &media.sha256_hash, None);
// Parse Range header // Parse Range header
let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok()); let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok());
@@ -3870,6 +4090,9 @@ async fn stream_handler(
.header(ACCEPT_RANGES, "bytes") .header(ACCEPT_RANGES, "bytes")
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}")) .header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
.header(CONTENT_LENGTH, chunk_size.to_string()) .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)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -3888,6 +4111,9 @@ async fn stream_handler(
.header(CONTENT_TYPE, media.mime_type.as_str()) .header(CONTENT_TYPE, media.mime_type.as_str())
.header(ACCEPT_RANGES, "bytes") .header(ACCEPT_RANGES, "bytes")
.header(CONTENT_LENGTH, file_size.to_string()) .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)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -4138,7 +4364,12 @@ async fn cover_response(
}; };
let media = sqlx::query_as::<_, MediaFileRow>( 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) .bind(media_file_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -4156,26 +4387,30 @@ async fn cover_response(
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk")); 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) .and_then(crate::agent::cover_variants::variant_by_name)
.map(|variant| { .map(|variant| {
let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant); let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant);
if variant_path.exists() { if variant_path.exists() {
(variant_path, "image/jpeg") (variant_path, "image/jpeg", Some(variant.name))
} else { } 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) let data = tokio::fs::read(&response_path)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .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() let response = cot::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(CONTENT_TYPE, content_type) .header(CONTENT_TYPE, content_type)
.header(CONTENT_LENGTH, data.len().to_string()) .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") .header("Cache-Control", "public, max-age=86400")
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -4183,6 +4418,13 @@ async fn cover_response(
Ok(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 // Player devices
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -6242,6 +6484,32 @@ impl App for PlayerApp {
}, },
"player_me", "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( Route::with_handler_and_name(
"/lastfm/status", "/lastfm/status",
get({ get({
+29
View File
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
pub(super) file_path: String, pub(super) file_path: String,
pub(super) mime_type: String, pub(super) mime_type: String,
pub(super) file_size_bytes: i64, pub(super) file_size_bytes: i64,
pub(super) sha256_hash: String,
pub(super) created_at: String,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -287,3 +289,30 @@ pub(super) struct ReleaseInfoRow {
pub(super) year: Option<i32>, pub(super) year: Option<i32>,
pub(super) cover_file_id: Option<i64>, 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
View File
@@ -496,14 +496,24 @@ impl PendingReview {
.await .await
} }
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> { /// Latest review row for an inbox path: `(id, status, updated_at)`.
let all = Self::objects().all(db).await?; ///
let exists = all.iter().any(|r| { /// Used by inbox_discover to decide whether a file needs a new review,
let s = r.status.as_str(); /// a requeue of its existing row, or nothing at all — without creating
// "rejected" and "failed" reviews should not block re-discovery /// a fresh row per retry.
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path) pub async fn latest_for_path(
}); pool: &sqlx::PgPool,
Ok(exists) 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 /// Mark all "processing" reviews as "failed" — called at scheduler
+137 -2
View File
@@ -1613,7 +1613,7 @@ tbody tr:hover {
</button> </button>
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0"> <button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
<i data-lucide="list-checks"></i> <i data-lucide="list-checks"></i>
Select filter <span x-text="`Select all (${fmt(reviews.total)})`"></span>
</button> </button>
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0"> <button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
<i data-lucide="x"></i> <i data-lucide="x"></i>
@@ -2265,6 +2265,73 @@ tbody tr:hover {
</div> </div>
</section> </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"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
@@ -2832,10 +2899,16 @@ function adminV2() {
agent_llm_auth: '', agent_llm_auth: '',
agent_confidence_threshold: '', agent_confidence_threshold: '',
agent_context_limit: '', agent_context_limit: '',
agent_concurrency: '' agent_concurrency: '',
federation_enabled: false,
federation_network_id: ''
}, },
settingsProbe: { status: 'idle', ok: false }, settingsProbe: { status: 'idle', ok: false },
settingsProbeLoading: false, settingsProbeLoading: false,
federationStatus: {},
federationLoading: false,
federationTicket: '',
fedConnectTicket: '',
settingsSaving: false, settingsSaving: false,
routeReady: false, routeReady: false,
poller: null, poller: null,
@@ -3115,6 +3188,7 @@ function adminV2() {
body: JSON.stringify(this.settingsDraft) body: JSON.stringify(this.settingsDraft)
}); });
await this.loadSettings(false); await this.loadSettings(false);
await this.loadFederation(false);
this.showToast('Settings saved'); this.showToast('Settings saved');
} catch (error) { } catch (error) {
this.showToast(error.message); this.showToast(error.message);
@@ -3128,11 +3202,72 @@ function adminV2() {
this.activeView = 'settings'; this.activeView = 'settings';
this.setRoute('#settings'); this.setRoute('#settings');
await this.loadSettings(); await this.loadSettings();
await this.loadFederation(false);
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') { if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false); 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) { async loadSettingsProbe(showErrors = true) {
this.settingsProbeLoading = true; this.settingsProbeLoading = true;
try { try {
+77
View File
@@ -620,6 +620,83 @@
</div> </div>
</div> </div>
</div> </div>
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
<div class="track-row artist-appearance-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<button class="artist-appearance-cover"
type="button"
@click.stop="$store.library.openRelease(track.release_id)"
:title="track.release_title"
aria-label="{{ t.player_release }}">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</button>
<div class="artist-appearance-copy">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</section>
</template>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type"> <template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group"> <section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2> <h2 class="artist-release-group-title" x-text="group.label"></h2>