Compare commits

...
26 Commits
Author SHA1 Message Date
Ultradesu b0d8929b4c Fixed liked track sorting
Build and Publish / Build and Publish Docker Image (push) Successful in 10m14s
2026-07-27 17:09:59 +01:00
Ultradesu 291265be7d hotfix search
Build and Publish / Build and Publish Docker Image (push) Successful in 7m32s
2026-07-27 17:02:20 +01:00
Ultradesu 624e75839d Reworked federation. Added settings page. Fixed connected devices.
Build and Publish / Build and Publish Docker Image (push) Successful in 5m21s
2026-07-27 16:44:16 +01:00
Ultradesu fd766cda24 Added catalog slices support
Build and Publish / Build and Publish Docker Image (push) Successful in 7m6s
2026-07-25 22:39:36 +03:00
Ultradesu 49000f716c Bump
Build and Publish / Build and Publish Docker Image (push) Successful in 4m3s
2026-07-25 00:47:16 +03:00
Ultradesu ee4990e2f1 Fixed control mode tracks art 2026-07-25 00:47:00 +03:00
Ultradesu e64b61c167 Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m11s
2026-07-24 17:08:00 +03:00
Ultradesu b737ced3fc Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m1s
2026-07-24 16:54:41 +03:00
Ultradesu c2bdd62a51 Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m10s
2026-07-24 16:45:11 +03:00
Ultradesu d1370c6a28 Improved UI. Added playlist manager
Build and Publish / Build and Publish Docker Image (push) Successful in 3m56s
2026-07-23 17:24:28 +03:00
Ultradesu 2fc5fd7960 Improved DHT publication. Adjust batching, added validation per key
Build and Publish / Build and Publish Docker Image (push) Successful in 4m9s
2026-07-23 16:44:35 +03:00
Ultradesu 63506e3af2 Aupdated FRID to v4 protocol
Build and Publish / Build and Publish Docker Image (push) Successful in 5m4s
2026-07-23 15:52:42 +03:00
Ultradesu 5b339aa921 Fixed content_id persistence
Build and Publish / Build and Publish Docker Image (push) Successful in 7m30s
2026-07-20 18:40:32 +03:00
Ultradesu 42c772f735 Fixed blake3 computation
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-07-20 18:24:39 +03:00
Ultradesu e738086573 Extend DHT scheme with content_id
Build and Publish / Build and Publish Docker Image (push) Successful in 5m6s
2026-07-20 18:05:31 +03:00
Ultradesu 4b7756c36e Extend DHT scheme with content_id 2026-07-20 18:05:16 +03:00
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
29 changed files with 16421 additions and 808 deletions
+1
View File
@@ -2,3 +2,4 @@
/nul
/.claude
/media
/federation
Generated
+2514 -603
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1,18 +1,24 @@
[package]
name = "furumusic"
version = "0.4.5"
version = "0.9.2"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
[dependencies]
cot = { version = "0.6.0", features = ["postgres", "json", "openapi", "swagger-ui"] }
# default-features off: cot's defaults include the sqlite backend, whose old
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
# binary). This server only ever talks PostgreSQL.
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json", "openapi", "swagger-ui"] }
schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
async-stream = "0.3"
bytes = "1"
tower = "0.5"
base64 = "0.22"
blake3 = "1"
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -28,5 +34,9 @@ anyhow = "1.0"
tokio-cron-scheduler = "0.15"
croner = "3"
async-trait = "0.1"
postcard = { version = "1", features = ["alloc"] }
uuid = "1"
librqbit = { version = "8.1.1", features = ["disable-upload"] }
# P2P federation: publishes the library into a shared DHT and serves audio /
# catalogs to furumi peers (TUI clients) over the frid stack.
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
+32
View File
@@ -415,6 +415,38 @@ impl App for AdminApp {
}),
"admin_v2_settings_probe",
),
Route::with_handler_and_name(
"/v2/api/federation",
get(move |session: Session, db: Database| async move {
v2::federation_status(session, db).await
}),
"admin_v2_federation_status",
),
Route::with_handler_and_name(
"/v2/api/federation/sync",
cot::router::method::post(move |session: Session, db: Database| async move {
v2::federation_sync(session, db).await
}),
"admin_v2_federation_sync",
),
Route::with_handler_and_name(
"/v2/api/federation/ticket",
get(move |session: Session, db: Database| async move {
v2::federation_ticket(session, db).await
}),
"admin_v2_federation_ticket",
),
Route::with_handler_and_name(
"/v2/api/federation/connect",
cot::router::method::post(
move |session: Session,
db: Database,
json: Json<v2::FederationConnectRequest>| async move {
v2::federation_connect(session, db, json).await
},
),
"admin_v2_federation_connect",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/toggle",
cot::router::method::post({
+99
View File
@@ -448,6 +448,12 @@ struct AdminSettingsValues {
agent_confidence_threshold: String,
agent_context_limit: String,
agent_concurrency: String,
#[serde(default)]
federation_enabled: bool,
#[serde(default)]
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -472,6 +478,9 @@ struct AdminSettingsSources {
agent_confidence_threshold: &'static str,
agent_context_limit: &'static str,
agent_concurrency: &'static str,
federation_enabled: &'static str,
federation_network_id: &'static str,
federation_save_on_listen: &'static str,
}
#[derive(Debug, Deserialize)]
@@ -496,6 +505,12 @@ pub(super) struct UpdateSettingsRequest {
agent_confidence_threshold: String,
agent_context_limit: String,
agent_concurrency: String,
#[serde(default)]
federation_enabled: bool,
#[serde(default)]
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -978,6 +993,15 @@ pub async fn update_settings(
"agent_concurrency",
body.agent_concurrency.trim().to_string(),
),
("federation_enabled", body.federation_enabled.to_string()),
(
"federation_network_id",
body.federation_network_id.trim().to_string(),
),
(
"federation_save_on_listen",
body.federation_save_on_listen.to_string(),
),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_string(), value);
@@ -986,9 +1010,78 @@ pub async fn update_settings(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
// Federation applies on the fly: (re)start or stop the node to match
// the freshly saved settings — no server restart involved.
let (fresh, _) = AppConfig::load_with_db(&db).await;
tokio::spawn(async move {
crate::federation::handle().apply(&fresh).await;
});
Json(serde_json::json!({ "ok": true })).into_response()
}
// ---------------------------------------------------------------------------
// Federation (status + manual controls)
// ---------------------------------------------------------------------------
pub async fn federation_status(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
Json(crate::federation::handle().status().await).into_response()
}
pub async fn federation_sync(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let fed = crate::federation::handle();
if let Err(err) = fed.sync_now().await {
return Ok(json_error(
StatusCode::BAD_REQUEST,
&format!("sync failed: {err:#}"),
));
}
Json(fed.status().await).into_response()
}
pub async fn federation_ticket(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
match crate::federation::handle().ticket().await {
Ok(ticket) => Json(serde_json::json!({ "ticket": ticket })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err:#}"))),
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FederationConnectRequest {
ticket: String,
}
pub async fn federation_connect(
session: Session,
db: Database,
Json(body): Json<FederationConnectRequest>,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
match crate::federation::handle().connect(&body.ticket).await {
Ok(peer) => Json(serde_json::json!({ "connected": peer })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err:#}"))),
}
}
pub async fn settings_probe(
session: Session,
db: Database,
@@ -1057,6 +1150,9 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
agent_context_limit: config.agent_context_limit.to_string(),
agent_concurrency: config.agent_concurrency.to_string(),
federation_enabled: config.federation_enabled,
federation_network_id: config.federation_network_id,
federation_save_on_listen: config.federation_save_on_listen,
},
sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(),
@@ -1079,6 +1175,9 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
agent_confidence_threshold: sources.agent_confidence_threshold.code(),
agent_context_limit: sources.agent_context_limit.code(),
agent_concurrency: sources.agent_concurrency.code(),
federation_enabled: sources.federation_enabled.code(),
federation_network_id: sources.federation_network_id.code(),
federation_save_on_listen: sources.federation_save_on_listen.code(),
},
}
}
+24
View File
@@ -135,6 +135,9 @@ pub struct ConfigSources {
pub agent_concurrency: ConfigSource,
pub lastfm_api_key: ConfigSource,
pub lastfm_shared_secret: ConfigSource,
pub federation_enabled: ConfigSource,
pub federation_network_id: ConfigSource,
pub federation_save_on_listen: ConfigSource,
}
impl Default for ConfigSources {
@@ -162,6 +165,9 @@ impl Default for ConfigSources {
agent_concurrency: ConfigSource::Default,
lastfm_api_key: ConfigSource::Default,
lastfm_shared_secret: ConfigSource::Default,
federation_enabled: ConfigSource::Default,
federation_network_id: ConfigSource::Default,
federation_save_on_listen: ConfigSource::Default,
}
}
}
@@ -270,6 +276,15 @@ pub struct AppConfig {
pub lastfm_api_key: String,
/// Last.fm shared secret for authenticated scrobbling calls.
pub lastfm_shared_secret: String,
/// Whether this server participates in the furumi federation (publishes
/// its library into the shared DHT and serves audio to peers).
pub federation_enabled: bool,
/// Federation network id — the shared secret every peer of the network
/// uses to find the others.
pub federation_network_id: String,
/// Whether a federated track requested for playback is imported into the
/// shared local library. This is a server-wide administrator policy.
pub federation_save_on_listen: bool,
}
impl Default for AppConfig {
@@ -297,6 +312,9 @@ impl Default for AppConfig {
agent_concurrency: 2,
lastfm_api_key: String::new(),
lastfm_shared_secret: String::new(),
federation_enabled: false,
federation_network_id: String::new(),
federation_save_on_listen: false,
}
}
}
@@ -325,6 +343,9 @@ impl_env_overrides!(
agent_concurrency,
lastfm_api_key,
lastfm_shared_secret,
federation_enabled,
federation_network_id,
federation_save_on_listen,
);
impl AppConfig {
@@ -452,6 +473,9 @@ impl AppConfig {
apply_db_field!(agent_concurrency);
apply_db_field!(lastfm_api_key);
apply_db_field!(lastfm_shared_secret);
apply_db_field!(federation_enabled);
apply_db_field!(federation_network_id);
apply_db_field!(federation_save_on_listen);
}
}
+536
View File
@@ -0,0 +1,536 @@
//! Receiving side of music federation.
//!
//! User-facing identity is content-addressed. An `(owner, item_id)` pair is
//! only a source locator and several locators may resolve the same track.
use std::collections::HashMap;
use anyhow::{Context, Result};
use music_dht::{ItemKind, LibraryItem, normalize_content_id};
use serde::Serialize;
use serde_json::{Value, json};
use sqlx::Row as _;
use tokio::io::AsyncReadExt;
use super::{Federation, now_iso};
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
pub struct TrackKeyDto {
pub content_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtistKeyDto {
pub normalized_name: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtistRefDto {
pub key: ArtistKeyDto,
pub name: String,
pub local_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReleaseKeyDto {
pub normalized_title: String,
pub primary_artists: Vec<String>,
pub release_type: Option<String>,
pub year: Option<i32>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReleaseRefDto {
pub key: ReleaseKeyDto,
pub local_id: Option<i64>,
pub title: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FederationSourceDto {
pub owner: String,
pub item_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalAvailabilityDto {
pub track_id: i64,
pub stream_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrackMetadataDto {
pub title: String,
pub artists: Vec<ArtistRefDto>,
pub featured_artists: Vec<ArtistRefDto>,
pub release: Option<ReleaseRefDto>,
pub year: Option<i32>,
pub duration_seconds: Option<f64>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub cover_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrackAvailabilityDto {
pub state: &'static str,
pub local: Option<LocalAvailabilityDto>,
pub federation: Vec<FederationSourceDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrackDto {
pub key: TrackKeyDto,
pub metadata: TrackMetadataDto,
pub availability: TrackAvailabilityDto,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchEvent {
pub search_id: String,
pub sequence: u64,
pub kind: &'static str,
pub peer: Option<String>,
pub entity_key: Value,
pub entity: Value,
}
impl Federation {
pub fn stream_artist_catalogs(
self: &std::sync::Arc<Self>,
name: String,
) -> tokio::sync::mpsc::UnboundedReceiver<Result<(String, music_dht::catalog::CatalogArtist)>>
{
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let federation = std::sync::Arc::clone(self);
tokio::spawn(async move {
let result = async {
let service = federation.service().await?;
let normalized = music_dht::normalize_name(&name);
let outcome = service
.search_network(&name)
.await
.map_err(|err| anyhow::anyhow!("federated artist search failed: {err}"))?;
let owners: std::collections::HashSet<_> = outcome
.network_results
.iter()
.filter(|item| {
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|| item
.artist_names
.iter()
.chain(&item.featured_artist_names)
.any(|artist| music_dht::normalize_name(artist) == normalized)
})
.map(|item| item.owner)
.collect();
for owner in owners {
let service = std::sync::Arc::clone(&service);
let sender = sender.clone();
let name = name.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
std::time::Duration::from_secs(8),
fetch_artist_catalog(&service, owner, &name),
)
.await
.map_err(|_| anyhow::anyhow!("catalog request timed out"))
.and_then(|result| result)
.map(|artist| (owner.to_string(), artist));
let _ = sender.send(result);
});
}
Ok::<(), anyhow::Error>(())
}
.await;
if let Err(err) = result {
let _ = sender.send(Err(err));
}
});
receiver
}
/// Performs one bounded DHT search and returns entity upserts. The HTTP
/// layer streams each upsert independently; catalog fan-out can append
/// events to the same contract without changing the browser model.
pub async fn search_events(&self, search_id: &str, query: &str) -> Result<Vec<SearchEvent>> {
let query = query.trim();
anyhow::ensure!(!query.is_empty(), "search query is empty");
anyhow::ensure!(query.chars().count() <= 200, "search query is too long");
let started = std::time::Instant::now();
let service = self.service().await?;
tracing::info!(
search_id,
query,
connected_peers = service.connected_peers().len(),
known_contacts = service.known_peers().len(),
"federated search started"
);
let own = service.endpoint_id();
let result = tokio::time::timeout(
std::time::Duration::from_secs(20),
service.search_network(query),
)
.await
.map_err(|_| anyhow::anyhow!("federated search timed out after 20 seconds"))?
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
tracing::info!(
search_id,
query,
local_results = result.local_results.len(),
network_results = result.network_results.len(),
queried_nodes = result.queried_nodes,
elapsed_ms = started.elapsed().as_millis() as u64,
"federated DHT search finished"
);
let all_items: Vec<LibraryItem> = result
.local_results
.into_iter()
.chain(result.network_results)
.collect();
let pool = self.pool().await?;
let mut by_content: HashMap<String, TrackDto> = HashMap::new();
for item in all_items.iter().filter(|item| item.kind == ItemKind::Track) {
let Some(content_id) = item.content_id.as_deref().and_then(normalize_content_id) else {
// A globally usable track reference must be verifiable.
continue;
};
let local = local_availability(&pool, &content_id).await?;
let source = FederationSourceDto {
owner: item.owner.to_string(),
item_id: hex(item.id.as_bytes()),
};
let entry = by_content.entry(content_id.clone()).or_insert_with(|| {
track_from_item(content_id.clone(), item, local, item.owner == own)
});
if !entry.availability.federation.iter().any(|candidate| {
candidate.owner == source.owner && candidate.item_id == source.item_id
}) {
entry.availability.federation.push(source);
}
if entry.availability.local.is_some() {
entry.availability.state = "local";
}
}
let mut tracks: Vec<_> = by_content.into_values().collect();
tracks.sort_by(|left, right| {
left.metadata
.title
.to_lowercase()
.cmp(&right.metadata.title.to_lowercase())
});
let mut events = Vec::with_capacity(all_items.len());
for (index, track) in tracks.into_iter().enumerate() {
persist_track_ref(&pool, &track).await?;
let peer = track
.availability
.federation
.first()
.map(|source| source.owner.clone());
events.push(SearchEvent {
search_id: search_id.to_owned(),
sequence: index as u64 + 1,
kind: "federation.track",
peer,
entity_key: serde_json::to_value(&track.key)?,
entity: serde_json::to_value(track)?,
});
}
let mut artist_peers: HashMap<String, (String, Vec<String>)> = HashMap::new();
let mut releases: HashMap<String, Value> = HashMap::new();
for item in &all_items {
match item.kind {
ItemKind::Artist => {
let key = music_dht::normalize_name(&item.name);
let entry = artist_peers
.entry(key)
.or_insert_with(|| (item.name.clone(), Vec::new()));
let owner = item.owner.to_string();
if !entry.1.contains(&owner) {
entry.1.push(owner);
}
}
ItemKind::Release => {
let artist_keys: Vec<String> = item
.artist_names
.iter()
.map(|name| music_dht::normalize_name(name))
.collect();
let normalized_title = music_dht::normalize_name(&item.name);
let cover_url = all_items
.iter()
.find(|track| {
track.kind == ItemKind::Track
&& track.release_title.as_deref().is_some_and(|title| {
music_dht::normalize_name(title) == normalized_title
})
&& track.year == item.year
})
.map(|track| {
format!(
"/api/player/federation/tracks/artwork?owner={}&item_id={}",
track.owner,
hex(track.id.as_bytes())
)
});
let key = format!(
"{}|{}|{}",
normalized_title,
artist_keys.join(","),
item.year.map_or_else(String::new, |year| year.to_string())
);
releases.entry(key.clone()).or_insert_with(|| {
json!({
"key": {
"normalized_title": music_dht::normalize_name(&item.name),
"primary_artists": artist_keys,
"release_type": null,
"year": item.year,
},
"title": item.name,
"artists": item.artist_names,
"year": item.year,
"cover_url": cover_url,
"sources": [{
"owner": item.owner.to_string(),
"item_id": hex(item.id.as_bytes()),
}],
})
});
}
ItemKind::Track => {}
}
}
for (key, (name, peers)) in artist_peers {
let sequence = events.len() as u64 + 1;
events.push(SearchEvent {
search_id: search_id.to_owned(),
sequence,
kind: "federation.artist",
peer: peers.first().cloned(),
entity_key: json!({ "normalized_name": key }),
entity: json!({
"key": { "normalized_name": key },
"name": name,
"image_url": null,
"peers": peers,
}),
});
}
for (key, release) in releases {
let sequence = events.len() as u64 + 1;
events.push(SearchEvent {
search_id: search_id.to_owned(),
sequence,
kind: "federation.release",
peer: None,
entity_key: json!({ "composite": key }),
entity: release,
});
}
tracing::info!(
search_id,
query,
events = events.len(),
elapsed_ms = started.elapsed().as_millis() as u64,
"federated search response ready"
);
Ok(events)
}
}
async fn fetch_artist_catalog(
service: &music_dht::MusicDhtService,
owner: music_dht::EndpointId,
artist: &str,
) -> Result<music_dht::catalog::CatalogArtist> {
let mut stream = service
.open_stream(owner, super::CATALOG_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
artist: artist.to_owned(),
want: Some("catalog".to_owned()),
..Default::default()
})?;
request.push(b'\n');
stream.send.write_all(&request).await?;
stream.send.finish()?;
let mut payload = Vec::new();
stream
.recv
.take(MAX_CATALOG_BYTES + 1)
.read_to_end(&mut payload)
.await?;
anyhow::ensure!(
payload.len() as u64 <= MAX_CATALOG_BYTES,
"catalog response is too large"
);
let response: music_dht::catalog::CatalogResponse =
serde_json::from_slice(&payload).context("invalid catalog response")?;
anyhow::ensure!(
response.ok,
"peer refused catalog: {}",
response.error.unwrap_or_else(|| "unknown error".to_owned())
);
response.artist.context("peer returned no artist catalog")
}
fn track_from_item(
content_id: String,
item: &LibraryItem,
local: Option<LocalAvailabilityDto>,
own: bool,
) -> TrackDto {
let owner = item.owner.to_string();
let item_id = hex(item.id.as_bytes());
let artists = artist_refs(&item.artist_names);
let featured_artists = artist_refs(&item.featured_artist_names);
let release = item.release_title.as_ref().map(|title| ReleaseRefDto {
key: ReleaseKeyDto {
normalized_title: music_dht::normalize_name(title),
primary_artists: item
.artist_names
.iter()
.map(|artist| music_dht::normalize_name(artist))
.collect(),
release_type: None,
year: item.year,
},
local_id: None,
title: title.clone(),
});
let state = if local.is_some() || own {
"local"
} else {
"federated"
};
TrackDto {
key: TrackKeyDto { content_id },
metadata: TrackMetadataDto {
title: item.name.clone(),
artists,
featured_artists,
release,
year: item.year,
duration_seconds: item.duration_seconds,
track_number: item.track_number,
disc_number: item.disc_number,
cover_url: Some(format!(
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
)),
},
availability: TrackAvailabilityDto {
state,
local,
federation: vec![FederationSourceDto { owner, item_id }],
},
}
}
fn artist_refs(names: &[String]) -> Vec<ArtistRefDto> {
names
.iter()
.map(|name| ArtistRefDto {
key: ArtistKeyDto {
normalized_name: music_dht::normalize_name(name),
},
name: name.clone(),
local_id: None,
})
.collect()
}
async fn local_availability(
pool: &sqlx::PgPool,
content_id: &str,
) -> Result<Option<LocalAvailabilityDto>> {
let row = sqlx::query(
"SELECT t.id
FROM furumusic__federation_content_id_cache c
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
WHERE c.content_id = $1 AND t.is_hidden = false
LIMIT 1",
)
.bind(content_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|row| {
let track_id: i64 = row.get(0);
LocalAvailabilityDto {
track_id,
stream_url: format!("/api/player/stream/{track_id}"),
}
}))
}
async fn persist_track_ref(pool: &sqlx::PgPool, track: &TrackDto) -> Result<()> {
let metadata = serde_json::to_value(&track.metadata)?;
let local_id = track
.availability
.local
.as_ref()
.map(|local| local.track_id);
let row = sqlx::query(
"INSERT INTO furumusic__track_ref
(content_id, local_track_id, title, release_title, year,
duration_seconds, metadata_json, metadata_authority, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'federation', $8, $8)
ON CONFLICT (content_id) DO UPDATE SET
local_track_id = COALESCE(furumusic__track_ref.local_track_id, EXCLUDED.local_track_id),
title = EXCLUDED.title,
release_title = EXCLUDED.release_title,
year = EXCLUDED.year,
duration_seconds = EXCLUDED.duration_seconds,
metadata_json = EXCLUDED.metadata_json,
updated_at = EXCLUDED.updated_at
RETURNING id",
)
.bind(&track.key.content_id)
.bind(local_id)
.bind(&track.metadata.title)
.bind(
track
.metadata
.release
.as_ref()
.map(|release| &release.title),
)
.bind(track.metadata.year)
.bind(track.metadata.duration_seconds)
.bind(metadata)
.bind(now_iso())
.fetch_one(pool)
.await
.context("persisting content-addressed track reference failed")?;
let track_ref_id: i64 = row.get(0);
for source in &track.availability.federation {
sqlx::query(
"INSERT INTO furumusic__federation_track_source
(track_ref_id, owner_peer_id, item_id, last_seen_ms, metadata_json)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (owner_peer_id, item_id) DO UPDATE SET
track_ref_id = EXCLUDED.track_ref_id,
last_seen_ms = EXCLUDED.last_seen_ms,
metadata_json = EXCLUDED.metadata_json",
)
.bind(track_ref_id)
.bind(&source.owner)
.bind(&source.item_id)
.bind(chrono::Utc::now().timestamp_millis())
.bind(json!({ "track": track.metadata }))
.execute(pool)
.await?;
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
File diff suppressed because it is too large Load Diff
+991
View File
@@ -0,0 +1,991 @@
//! 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`, `federation_save_on_listen`) and apply on the fly — saving the settings
//! starts, stops or re-joins the node without a server restart.
pub mod client;
pub mod devices;
mod receive;
mod serve;
mod storage;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use anyhow::{Context, Result};
use music_dht::{
ByteStream, ByteStreamConnectionStats, 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);
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
struct Running {
service: Arc<MusicDhtService>,
network_name: String,
tasks: Vec<tokio::task::JoinHandle<()>>,
}
struct ContentHashJob {
media_file_id: i64,
sha256_hash: String,
file_path: String,
}
#[derive(Clone)]
struct CachedArtwork {
bytes: Vec<u8>,
mime: String,
fetched_at: std::time::Instant,
}
#[derive(Debug, Clone)]
struct TransportSample {
at: String,
protocol: &'static str,
direction: &'static str,
phase: &'static str,
peer_id: String,
selected_path: String,
open_paths: usize,
direct_paths: usize,
relay_paths: usize,
custom_paths: usize,
selected_rtt_ms: Option<u64>,
selected_tx_bytes: u64,
selected_rx_bytes: u64,
total_tx_bytes: u64,
total_rx_bytes: u64,
lost_packets: u64,
lost_bytes: u64,
}
impl TransportSample {
fn from_stats(
protocol: &'static str,
direction: &'static str,
phase: &'static str,
stats: ByteStreamConnectionStats,
) -> Self {
Self {
at: now_iso(),
protocol,
direction,
phase,
peer_id: stats.peer_id.to_string(),
selected_path: stats.selected_path.as_str().to_string(),
open_paths: stats.open_paths,
direct_paths: stats.direct_paths,
relay_paths: stats.relay_paths,
custom_paths: stats.custom_paths,
selected_rtt_ms: stats
.selected_rtt
.map(|duration| duration.as_millis() as u64),
selected_tx_bytes: stats.selected_tx_bytes,
selected_rx_bytes: stats.selected_rx_bytes,
total_tx_bytes: stats.total_tx_bytes,
total_rx_bytes: stats.total_rx_bytes,
lost_packets: stats.lost_packets,
lost_bytes: stats.lost_bytes,
}
}
}
#[derive(Debug, Default)]
struct TransportStatsState {
total_samples: u64,
direct_samples: u64,
relay_samples: u64,
custom_samples: u64,
unknown_samples: u64,
audio_samples: u64,
catalog_samples: u64,
sync_samples: u64,
last: VecDeque<TransportSample>,
}
#[derive(Debug, Default)]
pub struct TransportStats {
inner: std::sync::Mutex<TransportStatsState>,
}
impl TransportStats {
fn reset(&self) {
*lock(&self.inner) = TransportStatsState::default();
}
fn record(
&self,
protocol: &'static str,
direction: &'static str,
phase: &'static str,
stats: ByteStreamConnectionStats,
) {
let sample = TransportSample::from_stats(protocol, direction, phase, stats);
let mut state = lock(&self.inner);
state.total_samples += 1;
match sample.selected_path.as_str() {
"direct" => state.direct_samples += 1,
"relay" => state.relay_samples += 1,
"custom" => state.custom_samples += 1,
_ => state.unknown_samples += 1,
}
match protocol {
"audio" => state.audio_samples += 1,
"catalog" => state.catalog_samples += 1,
"device-sync" => state.sync_samples += 1,
_ => {}
}
state.last.push_front(sample);
while state.last.len() > TRANSPORT_SAMPLE_LIMIT {
state.last.pop_back();
}
}
fn snapshot(&self) -> Value {
let state = lock(&self.inner);
let latest = state.last.front();
json!({
"total_samples": state.total_samples,
"direct_samples": state.direct_samples,
"relay_samples": state.relay_samples,
"custom_samples": state.custom_samples,
"unknown_samples": state.unknown_samples,
"audio_samples": state.audio_samples,
"catalog_samples": state.catalog_samples,
"sync_samples": state.sync_samples,
"last_path": latest.map(|sample| sample.selected_path.clone()),
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
"last_peer": latest.map(|sample| sample.peer_id.clone()),
"last": state.last.iter().map(|sample| json!({
"at": sample.at,
"protocol": sample.protocol,
"direction": sample.direction,
"phase": sample.phase,
"peer_id": sample.peer_id,
"selected_path": sample.selected_path,
"open_paths": sample.open_paths,
"direct_paths": sample.direct_paths,
"relay_paths": sample.relay_paths,
"custom_paths": sample.custom_paths,
"selected_rtt_ms": sample.selected_rtt_ms,
"selected_tx_bytes": sample.selected_tx_bytes,
"selected_rx_bytes": sample.selected_rx_bytes,
"total_tx_bytes": sample.total_tx_bytes,
"total_rx_bytes": sample.total_rx_bytes,
"lost_packets": sample.lost_packets,
"lost_bytes": sample.lost_bytes,
})).collect::<Vec<_>>(),
})
}
}
pub fn record_stream_transport(
stats: &Arc<TransportStats>,
protocol: &'static str,
direction: &'static str,
phase: &'static str,
stream: &ByteStream,
) {
stats.record(protocol, direction, phase, stream.connection_stats());
}
pub struct Federation {
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
data_dir: PathBuf,
database_url: std::sync::Mutex<String>,
storage_dir: std::sync::Mutex<String>,
save_on_listen: std::sync::atomic::AtomicBool,
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
content_pending: std::sync::Mutex<HashSet<i64>>,
prepared_cache: std::sync::Mutex<HashMap<String, (PathBuf, String)>>,
artwork_cache: std::sync::Mutex<HashMap<String, CachedArtwork>>,
download_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
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>>,
transport_stats: Arc<TransportStats>,
}
fn now_iso() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The process-wide federation handle.
pub fn handle() -> Arc<Federation> {
static HANDLE: OnceLock<Arc<Federation>> = OnceLock::new();
Arc::clone(HANDLE.get_or_init(|| {
Arc::new(Federation {
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
database_url: std::sync::Mutex::new(String::new()),
storage_dir: std::sync::Mutex::new(String::new()),
save_on_listen: std::sync::atomic::AtomicBool::new(false),
content_cache: std::sync::Mutex::new(Default::default()),
content_pending: std::sync::Mutex::new(Default::default()),
prepared_cache: std::sync::Mutex::new(Default::default()),
artwork_cache: std::sync::Mutex::new(Default::default()),
download_locks: std::sync::Mutex::new(Default::default()),
pool: tokio::sync::OnceCell::new(),
running: tokio::sync::Mutex::new(None),
last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(None),
transport_stats: Arc::new(TransportStats::default()),
})
}))
}
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',
'federation_save_on_listen', '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,
"federation_save_on_listen" => {
if let Ok(parsed) = value.parse() {
effective.federation_save_on_listen = parsed;
}
}
"agent_storage_dir" => {
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
}
_ => {}
}
}
self.apply(&effective).await;
}
/// Applies the effective configuration: starts, stops or re-joins the
/// node. Called at boot and every time the admin settings are saved.
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
*lock(&self.database_url) = config.database_url.clone();
*lock(&self.storage_dir) = config.agent_storage_dir.clone();
self.save_on_listen.store(
config.federation_save_on_listen,
std::sync::atomic::Ordering::Relaxed,
);
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?;
self.transport_stats.reset();
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)
.stream_protocol(devices::SYNC_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(),
Arc::clone(&self.transport_stats),
));
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.clone(),
storage_dir,
service.endpoint_id(),
Arc::clone(&self.transport_stats),
));
let device_acceptor = service
.stream_acceptor(devices::SYNC_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
let device_hub = crate::player::PlayerDeviceHub::shared();
let device_task = tokio::spawn(devices::serve_peers(
device_acceptor,
pool.clone(),
Arc::clone(&service),
Arc::clone(&device_hub),
Arc::clone(&self.transport_stats),
));
let device_sync_task = tokio::spawn(devices::sync_loop(
pool,
Arc::clone(&service),
device_hub,
Arc::clone(&self.transport_stats),
));
*guard = Some(Running {
service,
network_name,
tasks: vec![
event_task,
sync_task,
audio_task,
catalog_task,
device_task,
device_sync_task,
],
});
self.set_error(None);
drop(guard);
// Publish right away instead of waiting for the first timer tick.
self.spawn_sync_soon().await;
Ok(())
}
async fn stop(&self) {
let mut guard = self.running.lock().await;
stop_running(guard.take()).await;
}
async fn service(&self) -> Result<Arc<MusicDhtService>> {
self.running
.lock()
.await
.as_ref()
.map(|running| Arc::clone(&running.service))
.context("federation is not running")
}
async fn spawn_sync_soon(self: &Arc<Self>) {
if let Ok(service) = self.service().await {
let fed = Arc::clone(self);
tokio::spawn(async move {
let _ = fed.sync_once(&service).await;
});
}
}
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
let service = self.service().await?;
let sync_stats = self.sync_once(&service).await?;
let publish_stats = match service.republish().await {
Ok(stats) => stats,
Err(err) => {
tracing::warn!("federation republish failed: {err}");
self.set_error(Some(format!("republish failed: {err}")));
anyhow::bail!("republish failed: {err}");
}
};
self.record_publish_success(sync_stats, publish_stats);
Ok(())
}
async fn sync_once(self: &Arc<Self>, service: &MusicDhtService) -> Result<SyncStats> {
let specs = match self.collect_specs().await {
Ok(specs) => specs,
Err(err) => {
tracing::warn!("federation sync: library read failed: {err:#}");
self.set_error(Some(format!("library read failed: {err}")));
anyhow::bail!("library read failed: {err}");
}
};
match service.sync_library(specs).await {
Ok(stats) => {
self.record_sync_success(stats);
if stats.failed > 0 {
self.set_error(Some(format!(
"{} item(s) failed to publish in the last sync",
stats.failed
)));
} else {
self.set_error(None);
}
Ok(stats)
}
Err(err) => {
tracing::warn!("federation sync failed: {err}");
self.set_error(Some(format!("sync failed: {err}")));
Err(anyhow::anyhow!("sync failed: {err}"))
}
}
}
fn record_sync_success(&self, stats: SyncStats) {
*lock(&self.last_sync) = Some(format!(
"{} (+{} ~{} {}, unchanged {}, failed {})",
now_iso(),
stats.added,
stats.updated,
stats.removed,
stats.unchanged,
stats.failed
));
}
fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) {
*lock(&self.last_sync) = Some(format!(
"{} (+{} ~{} {}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})",
now_iso(),
sync_stats.added,
sync_stats.updated,
sync_stats.removed,
sync_stats.unchanged,
sync_stats.failed,
publish_stats.records,
publish_stats.keys,
publish_stats.remote_nodes,
));
self.set_error(None);
}
/// Everything the regular player shows, as DHT item specs: non-hidden
/// artists, releases and tracks (a track also hides with its release).
async fn collect_specs(self: &Arc<Self>) -> Result<Vec<ItemSpec>> {
let pool = self.pool().await?;
let mut specs = Vec::new();
let artists = sqlx::query("SELECT id, name FROM furumusic__artist WHERE is_hidden = false")
.fetch_all(&pool)
.await?;
for row in &artists {
let id: i64 = row.get(0);
specs.push(ItemSpec {
local_key: format!("artist:{id}"),
kind: ItemKind::Artist,
name: row.get(1),
artist_names: Vec::new(),
featured_artist_names: Vec::new(),
year: None,
release_type: None,
release_title: None,
track_number: None,
disc_number: None,
duration_seconds: None,
content_id: None,
});
}
let release_artists = sqlx::query(
"SELECT ra.release_id, a.name FROM furumusic__release_artist ra
JOIN furumusic__artist a ON a.id = ra.artist_id
ORDER BY ra.release_id, ra.position",
)
.fetch_all(&pool)
.await?;
let mut artists_of_release: std::collections::HashMap<i64, Vec<String>> =
Default::default();
for row in &release_artists {
artists_of_release
.entry(row.get(0))
.or_default()
.push(row.get(1));
}
let releases = sqlx::query(
"SELECT id, title, year, release_type FROM furumusic__release
WHERE is_hidden = false",
)
.fetch_all(&pool)
.await?;
for row in &releases {
let id: i64 = row.get(0);
specs.push(ItemSpec {
local_key: format!("release:{id}"),
kind: ItemKind::Release,
name: row.get(1),
artist_names: artists_of_release.remove(&id).unwrap_or_default(),
featured_artist_names: Vec::new(),
year: row.get(2),
release_type: row.get(3),
release_title: None,
track_number: None,
disc_number: None,
duration_seconds: None,
content_id: None,
});
}
let track_artists = sqlx::query(
"SELECT ta.track_id, a.name, ta.role FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.role IN ('main', 'featuring')
ORDER BY ta.track_id,
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
ta.position",
)
.fetch_all(&pool)
.await?;
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
let mut featured_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
for row in &track_artists {
let id: i64 = row.get(0);
let name: String = row.get(1);
if row.get::<String, _>(2) == "featuring" {
featured_of_track.entry(id).or_default().push(name);
} else {
artists_of_track.entry(id).or_default().push(name);
}
}
let tracks = sqlx::query(
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds,
r.title, r.release_type, t.track_number, t.disc_number,
t.audio_file_id, m.file_path, m.sha256_hash, c.content_id
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file m ON m.id = t.audio_file_id
LEFT JOIN furumusic__federation_content_id_cache c
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
WHERE t.is_hidden = false AND r.is_hidden = false",
)
.fetch_all(&pool)
.await?;
let storage_dir = lock(&self.storage_dir).clone();
let mut content_hash_jobs = Vec::new();
for row in &tracks {
let id: i64 = row.get(0);
let duration: f64 = row.get(3);
let media_file_id: i64 = row.get(8);
let file_path: String = row.get(9);
let sha256_hash: String = row.get(10);
let cached_content_id: Option<String> = row.get(11);
let content_id = cached_content_id
.or_else(|| self.cached_content_id_for_media(media_file_id, &sha256_hash));
if content_id.is_none()
&& !storage_dir.trim().is_empty()
&& self.mark_content_hash_pending(media_file_id)
{
content_hash_jobs.push(ContentHashJob {
media_file_id,
sha256_hash,
file_path,
});
}
specs.push(ItemSpec {
local_key: format!("track:{id}"),
kind: ItemKind::Track,
name: row.get(1),
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
featured_artist_names: featured_of_track.remove(&id).unwrap_or_default(),
year: row.get(2),
release_type: row.get(5),
release_title: Some(row.get(4)),
track_number: row.get(6),
disc_number: row.get(7),
duration_seconds: (duration > 0.0).then_some(duration),
content_id,
});
}
self.spawn_content_warmer(pool.clone(), storage_dir, content_hash_jobs);
Ok(specs)
}
fn cached_content_id_for_media(&self, media_file_id: i64, sha256_hash: &str) -> Option<String> {
if let Some((cached_hash, content_id)) = lock(&self.content_cache).get(&media_file_id)
&& cached_hash == sha256_hash
{
return Some(content_id.clone());
}
None
}
fn mark_content_hash_pending(&self, media_file_id: i64) -> bool {
lock(&self.content_pending).insert(media_file_id)
}
fn spawn_content_warmer(
self: &Arc<Self>,
pool: PgPool,
storage_dir: String,
jobs: Vec<ContentHashJob>,
) {
if jobs.is_empty() {
return;
}
let fed = Arc::clone(self);
tokio::spawn(async move {
let total = jobs.len();
let mut stored = 0usize;
for job in jobs {
let job_storage_dir = storage_dir.clone();
let job_file_path = job.file_path.clone();
let content_id = tokio::task::spawn_blocking(move || {
audio_content_id(&job_storage_dir, &job_file_path)
})
.await
.ok()
.flatten();
lock(&fed.content_pending).remove(&job.media_file_id);
if let Some(content_id) = content_id {
lock(&fed.content_cache).insert(
job.media_file_id,
(job.sha256_hash.clone(), content_id.clone()),
);
if let Err(err) =
persist_content_id(&pool, job.media_file_id, &job.sha256_hash, &content_id)
.await
{
tracing::warn!(
media_file_id = job.media_file_id,
"federation content-id cache write failed: {err:#}"
);
} else {
stored += 1;
}
}
}
tracing::info!(total, stored, "federation content-id cache warm finished");
});
}
/// Live status for the admin page.
pub async fn status(&self) -> Value {
let guard = self.running.lock().await;
let node = match guard.as_ref() {
Some(running) => {
let service = &running.service;
let published = service
.list_local_items()
.await
.map(|items| items.len())
.unwrap_or(0);
let peers: Vec<String> = service
.connected_peers()
.iter()
.map(|p| p.to_string())
.collect();
json!({
"running": true,
"network": running.network_name,
"endpoint_id": service.endpoint_id().to_string(),
"connected_peers": peers,
"known_contacts": service.known_peers().len(),
"published_items": published,
"transport": self.transport_stats.snapshot(),
})
}
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())
}
pub async fn fed_device_status(
&self,
user_id: i64,
user_name: &str,
) -> Result<devices::FedDeviceStatus> {
let pool = self.pool().await?;
devices::status(&pool, user_id, user_name).await
}
pub async fn fed_device_invite(&self, user_id: i64, user_name: &str) -> Result<String> {
let service = self.service().await?;
let pool = self.pool().await?;
devices::create_invite(&pool, service, user_id, user_name).await
}
pub async fn fed_device_connect(
&self,
user_id: i64,
user_name: &str,
invite: &str,
) -> Result<String> {
let network_id = devices::invite_network_id(invite)?;
{
let guard = self.running.lock().await;
let Some(running) = guard.as_ref() else {
anyhow::bail!("federation is not running");
};
let expected = NetworkId::from_name(&running.network_name);
anyhow::ensure!(
network_id == expected,
"device invite belongs to a different federation network"
);
}
let service = self.service().await?;
let pool = self.pool().await?;
devices::connect_invite(
&pool,
service,
crate::player::PlayerDeviceHub::shared(),
Arc::clone(&self.transport_stats),
user_id,
user_name,
invite,
)
.await
}
pub async fn fed_device_answer_pairing(
&self,
user_id: i64,
request_id: &str,
accept: bool,
use_requester_group: bool,
) -> Result<()> {
let pool = self.pool().await?;
devices::answer_pairing(&pool, user_id, request_id, accept, use_requester_group).await
}
pub async fn fed_device_revoke(&self, user_id: i64, device_id: &str) -> Result<()> {
let pool = self.pool().await?;
devices::revoke_device(&pool, user_id, device_id).await
}
pub async fn fed_device_sync_now(&self, user_id: i64) -> Result<()> {
let service = self.service().await?;
let pool = self.pool().await?;
devices::sync_once(
&pool,
service,
crate::player::PlayerDeviceHub::shared(),
Arc::clone(&self.transport_stats),
user_id,
)
.await
}
pub async fn fed_device_web_command(
&self,
user_id: i64,
target_device_id: &str,
command: &str,
payload: serde_json::Value,
current_state: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let pool = self.pool().await?;
devices::record_web_playback_command(
&pool,
user_id,
target_device_id,
command,
payload,
current_state,
)
.await
}
pub async fn fed_device_web_active_transfer(
&self,
user_id: i64,
target_device_id: &str,
previous_device_id: Option<&str>,
state: serde_json::Value,
) -> Result<()> {
let pool = self.pool().await?;
devices::record_web_active_transfer(
&pool,
user_id,
target_device_id,
previous_device_id,
state,
)
.await
}
pub async fn fed_device_web_active_takeover(
&self,
user_id: i64,
previous_device_id: &str,
state: serde_json::Value,
) -> Result<()> {
let pool = self.pool().await?;
devices::record_web_active_takeover(&pool, user_id, previous_device_id, state).await
}
}
async fn persist_content_id(
pool: &PgPool,
media_file_id: i64,
sha256_hash: &str,
content_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO furumusic__federation_content_id_cache
(media_file_id, sha256_hash, content_id, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (media_file_id) DO UPDATE SET
sha256_hash = EXCLUDED.sha256_hash,
content_id = EXCLUDED.content_id,
updated_at = EXCLUDED.updated_at",
)
.bind(media_file_id)
.bind(sha256_hash)
.bind(content_id)
.bind(now_iso())
.execute(pool)
.await?;
Ok(())
}
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
if storage_dir.trim().is_empty() {
return None;
}
let path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
let mut file = std::fs::File::open(path).ok()?;
let mut hasher = blake3::Hasher::new();
std::io::copy(&mut file, &mut hasher).ok()?;
Some(format!("b3:{}", hasher.finalize().to_hex()))
}
async fn stop_running(running: Option<Running>) {
let Some(running) = running else { return };
for task in &running.tasks {
task.abort();
}
if let Err(err) = running.service.shutdown().await {
tracing::warn!("federation node shutdown reported an error: {err}");
}
tracing::info!("federation stopped");
}
+914
View File
@@ -0,0 +1,914 @@
//! Verified federated audio download and trusted materialization.
//!
//! This module never writes inbox, processing-task, or review tables. Peer
//! metadata is the authority for this import path.
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{Context, Result};
use music_dht::EndpointId;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use super::{Federation, now_iso};
const MAX_LINE: usize = 4096;
const MAX_AUDIO_BYTES: u64 = 4 * 1024 * 1024 * 1024;
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
#[derive(Debug, Clone, Deserialize, Serialize)]
struct TrackMetadata {
title: String,
#[serde(default)]
artists: Vec<String>,
#[serde(default)]
featured_artists: Vec<String>,
#[serde(default)]
album_artists: Vec<String>,
#[serde(default)]
release_title: String,
release_type: Option<String>,
year: Option<i32>,
track_number: Option<i32>,
disc_number: Option<i32>,
}
#[derive(Serialize)]
struct AudioRequest<'a> {
item_id: &'a str,
offset: u64,
want_cover: bool,
}
#[derive(Deserialize)]
struct AudioHeader {
ok: bool,
#[serde(default)]
error: Option<String>,
#[serde(default)]
mime_type: String,
#[serde(default)]
total_size: u64,
#[serde(default)]
metadata: Option<TrackMetadata>,
#[serde(default)]
cover_size: u64,
#[serde(default)]
cover_mime: String,
#[serde(default)]
artist_image_size: u64,
#[serde(default)]
artist_image_mime: String,
}
pub struct PreparedTrack {
pub local_track_id: Option<i64>,
pub stream_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DownloadProgress {
pub phase: &'static str,
pub received: u64,
pub total: u64,
}
struct Downloaded {
path: PathBuf,
mime: String,
metadata: TrackMetadata,
cover: Option<(Vec<u8>, String)>,
artist_image: Option<(Vec<u8>, String)>,
}
impl Federation {
pub async fn discover_catalog_artwork(
&self,
artist: &str,
release: Option<&str>,
) -> Result<Option<(Vec<u8>, String)>> {
let service = self.service().await?;
let normalized = music_dht::normalize_name(artist);
let outcome = service
.search_network(artist)
.await
.map_err(|err| anyhow::anyhow!("artwork peer discovery failed: {err}"))?;
let mut owners = Vec::new();
for item in outcome.local_results.iter().chain(&outcome.network_results) {
let matches = (item.kind == music_dht::ItemKind::Artist
&& item.normalized_name == normalized)
|| item
.artist_names
.iter()
.chain(&item.featured_artist_names)
.any(|name| music_dht::normalize_name(name) == normalized);
let owner = item.owner.to_string();
if matches && !owners.contains(&owner) {
owners.push(owner);
}
}
for owner in owners {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
self.catalog_artwork(&owner, artist, release),
)
.await
{
Ok(Ok(Some(artwork))) => return Ok(Some(artwork)),
Ok(Ok(None)) => {}
Ok(Err(err)) => {
tracing::debug!(%owner, %artist, ?release, "catalog artwork peer failed: {err:#}");
}
Err(_) => {
tracing::debug!(%owner, %artist, ?release, "catalog artwork peer timed out");
}
}
}
Ok(None)
}
pub async fn catalog_artwork(
&self,
owner: &str,
artist: &str,
release: Option<&str>,
) -> Result<Option<(Vec<u8>, String)>> {
let cache_key = format!("catalog:{owner}:{artist}:{}", release.unwrap_or_default());
if let Some(cached) = cached_artwork(self, &cache_key) {
return Ok(Some(cached));
}
let owner = EndpointId::from_str(owner).context("invalid federation owner")?;
let service = self.service().await?;
let mut stream = service
.open_stream(owner, super::CATALOG_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
artist: artist.to_owned(),
want: Some(if release.is_some() {
"release_cover".to_owned()
} else {
"artist_image".to_owned()
}),
release: release.map(str::to_owned),
..Default::default()
})?;
request.push(b'\n');
stream.send.write_all(&request).await?;
stream.send.finish()?;
let header: music_dht::catalog::CatalogImageHeader =
serde_json::from_slice(&read_line(&mut stream.recv).await?)
.context("invalid catalog artwork response")?;
if !header.ok || header.size == 0 {
return Ok(None);
}
anyhow::ensure!(
header.size <= MAX_IMAGE_BYTES,
"catalog artwork is too large"
);
anyhow::ensure!(
header.mime_type.starts_with("image/"),
"invalid catalog artwork mime type"
);
let mut bytes = vec![0; header.size as usize];
stream.recv.read_exact(&mut bytes).await?;
let artwork = (bytes, header.mime_type);
cache_artwork(self, cache_key, &artwork);
Ok(Some(artwork))
}
pub async fn track_artwork(
&self,
owner: &str,
item_id: &str,
) -> Result<Option<(Vec<u8>, String)>> {
let cache_key = format!("{owner}:{item_id}");
if let Some(cached) = cached_artwork(self, &cache_key) {
return Ok(Some(cached));
}
let owner = EndpointId::from_str(owner).context("invalid federation owner")?;
anyhow::ensure!(item_id.len() == 64, "invalid federation item id");
let service = self.service().await?;
let mut stream = service
.open_stream(owner, super::AUDIO_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach track owner: {err}"))?;
write_line(
&mut stream.send,
&AudioRequest {
item_id,
offset: 0,
want_cover: true,
},
)
.await?;
stream.send.finish()?;
let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
.context("invalid artwork response")?;
anyhow::ensure!(
header.ok,
"peer refused artwork: {}",
header.error.unwrap_or_else(|| "unknown error".to_string())
);
let artwork = read_segment(
&mut stream.recv,
header.cover_size,
&header.cover_mime,
"cover",
)
.await?;
if let Some((bytes, mime)) = artwork {
let artwork = (bytes, mime);
cache_artwork(self, cache_key, &artwork);
Ok(Some(artwork))
} else {
Ok(None)
}
}
pub async fn prepare_content_with_progress<F>(
self: &std::sync::Arc<Self>,
content_id: &str,
owner: &str,
item_id: &str,
mut progress: F,
) -> Result<PreparedTrack>
where
F: FnMut(DownloadProgress) + Send,
{
progress(DownloadProgress {
phase: "checking",
received: 0,
total: 0,
});
let content_id =
music_dht::normalize_content_id(content_id).context("invalid content id")?;
let pool = self.pool().await?;
let token = content_id.trim_start_matches("b3:").to_owned();
let download_lock = {
let mut locks = super::lock(&self.download_locks);
std::sync::Arc::clone(
locks
.entry(content_id.clone())
.or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))),
)
};
let _download_guard = download_lock.lock().await;
if let Some(track_id) = local_track_id(&pool, &content_id).await? {
progress(DownloadProgress {
phase: "ready",
received: 1,
total: 1,
});
return Ok(PreparedTrack {
local_track_id: Some(track_id),
stream_url: format!("/api/player/stream/{track_id}"),
});
}
let save = self
.save_on_listen
.load(std::sync::atomic::Ordering::Relaxed);
if !save
&& let Some((_path, _mime)) = super::lock(&self.prepared_cache).get(&token).cloned()
{
return Ok(PreparedTrack {
local_track_id: None,
stream_url: format!("/api/player/federation/cache/{token}"),
});
}
let owner = EndpointId::from_str(owner).context("invalid federation owner")?;
anyhow::ensure!(item_id.len() == 64, "invalid federation item id");
let service = self.service().await?;
let storage_root = if save {
PathBuf::from(super::lock(&self.storage_dir).clone())
} else {
PathBuf::from(crate::media_paths::resolve_config_path("federation-cache"))
};
anyhow::ensure!(
!storage_root.as_os_str().is_empty(),
"media storage directory is not configured"
);
let dir = storage_root.join("federation");
tokio::fs::create_dir_all(&dir).await?;
let downloaded =
download(&service, owner, item_id, &content_id, &dir, &mut progress).await?;
if !save {
super::lock(&self.prepared_cache)
.insert(token.clone(), (downloaded.path, downloaded.mime));
return Ok(PreparedTrack {
local_track_id: None,
stream_url: format!("/api/player/federation/cache/{token}"),
});
}
progress(DownloadProgress {
phase: "saving",
received: 1,
total: 1,
});
let track_id = materialize(&pool, &storage_root, &content_id, downloaded).await?;
// The normal periodic sync will publish it; this immediate sync keeps
// save-on-listen useful to the federation without waiting a minute.
if let Err(err) = self.sync_now().await {
tracing::warn!(track_id, "post-import federation publish failed: {err:#}");
}
Ok(PreparedTrack {
local_track_id: Some(track_id),
stream_url: format!("/api/player/stream/{track_id}"),
})
}
pub fn prepared_cache_file(&self, token: &str) -> Option<(PathBuf, String)> {
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
super::lock(&self.prepared_cache).get(token).cloned()
}
}
fn cached_artwork(federation: &Federation, key: &str) -> Option<(Vec<u8>, String)> {
let mut cache = super::lock(&federation.artwork_cache);
let cached = cache.get(key).cloned()?;
if cached.fetched_at.elapsed() > ARTWORK_CACHE_TTL {
cache.remove(key);
return None;
}
Some((cached.bytes, cached.mime))
}
fn cache_artwork(federation: &Federation, key: String, artwork: &(Vec<u8>, String)) {
let mut cache = super::lock(&federation.artwork_cache);
if cache.len() >= 512 {
cache.retain(|_, value| value.fetched_at.elapsed() <= ARTWORK_CACHE_TTL);
if cache.len() >= 512
&& let Some(oldest) = cache
.iter()
.min_by_key(|(_, value)| value.fetched_at)
.map(|(key, _)| key.clone())
{
cache.remove(&oldest);
}
}
cache.insert(
key,
super::CachedArtwork {
bytes: artwork.0.clone(),
mime: artwork.1.clone(),
fetched_at: std::time::Instant::now(),
},
);
}
async fn download(
service: &music_dht::MusicDhtService,
owner: EndpointId,
item_id: &str,
content_id: &str,
dir: &Path,
progress: &mut (impl FnMut(DownloadProgress) + Send),
) -> Result<Downloaded> {
progress(DownloadProgress {
phase: "connecting",
received: 0,
total: 0,
});
let mut stream = service
.open_stream(owner, super::AUDIO_ALPN)
.await
.map_err(|err| anyhow::anyhow!("cannot reach track owner: {err}"))?;
write_line(
&mut stream.send,
&AudioRequest {
item_id,
offset: 0,
want_cover: true,
},
)
.await?;
stream.send.finish()?;
let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
.context("invalid audio response")?;
anyhow::ensure!(
header.ok,
"peer refused audio: {}",
header.error.unwrap_or_else(|| "unknown error".to_string())
);
anyhow::ensure!(
header.total_size > 0 && header.total_size <= MAX_AUDIO_BYTES,
"invalid federated audio size"
);
progress(DownloadProgress {
phase: "downloading",
received: 0,
total: header.total_size,
});
let metadata = header.metadata.context("peer returned no track metadata")?;
let cover = read_segment(
&mut stream.recv,
header.cover_size,
&header.cover_mime,
"cover",
)
.await?;
let artist_image = read_segment(
&mut stream.recv,
header.artist_image_size,
&header.artist_image_mime,
"artist image",
)
.await?;
let stem = content_id.trim_start_matches("b3:");
let extension = audio_extension(&header.mime_type);
let final_path = dir.join(format!("{stem}.{extension}"));
let part_path = dir.join(format!(".{stem}.{extension}.part"));
let mut file = tokio::fs::File::create(&part_path).await?;
let mut hasher = blake3::Hasher::new();
let mut received = 0u64;
let mut buf = vec![0u8; 64 * 1024];
while received < header.total_size {
let remaining = (header.total_size - received).min(buf.len() as u64) as usize;
let count = stream.recv.read(&mut buf[..remaining]).await?.unwrap_or(0);
anyhow::ensure!(count > 0, "audio stream ended early");
file.write_all(&buf[..count]).await?;
hasher.update(&buf[..count]);
received += count as u64;
progress(DownloadProgress {
phase: "downloading",
received,
total: header.total_size,
});
}
file.flush().await?;
drop(file);
let actual = format!("b3:{}", hasher.finalize().to_hex());
progress(DownloadProgress {
phase: "verifying",
received,
total: header.total_size,
});
if actual != content_id {
let _ = tokio::fs::remove_file(&part_path).await;
anyhow::bail!("downloaded audio content id mismatch");
}
tokio::fs::rename(&part_path, &final_path).await?;
Ok(Downloaded {
path: final_path,
mime: header.mime_type,
metadata,
cover,
artist_image,
})
}
async fn materialize(
pool: &sqlx::PgPool,
storage_root: &Path,
content_id: &str,
downloaded: Downloaded,
) -> Result<i64> {
if let Some(track_id) = local_track_id(pool, content_id).await? {
return Ok(track_id);
}
let bytes = tokio::fs::read(&downloaded.path).await?;
let sha256 = format!("{:x}", Sha256::digest(&bytes));
let relative = downloaded
.path
.strip_prefix(storage_root)
.unwrap_or(&downloaded.path)
.to_string_lossy()
.into_owned();
let mut tx = pool.begin().await?;
let existing_media: Option<i64> = sqlx::query_scalar(
"SELECT id FROM furumusic__media_file
WHERE file_type = 'audio' AND sha256_hash = $1 LIMIT 1",
)
.bind(&sha256)
.fetch_optional(&mut *tx)
.await?;
let media_id = match existing_media {
Some(id) => id,
None => {
sqlx::query_scalar(
"INSERT INTO furumusic__media_file
(file_type, file_path, original_filename, mime_type,
file_size_bytes, sha256_hash, audio_format,
uploaded_by_user_id, uploader_name, created_at)
VALUES ('audio', $1, $2, $3, $4, $5, $6, NULL, 'Federation', $7)
RETURNING id",
)
.bind(&relative)
.bind(
downloaded
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("federated-audio"),
)
.bind(&downloaded.mime)
.bind(bytes.len() as i64)
.bind(&sha256)
.bind(audio_extension(&downloaded.mime))
.bind(now_iso())
.fetch_one(&mut *tx)
.await?
}
};
sqlx::query(
"INSERT INTO furumusic__federation_content_id_cache
(media_file_id, sha256_hash, content_id, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (media_file_id) DO UPDATE SET
sha256_hash = EXCLUDED.sha256_hash,
content_id = EXCLUDED.content_id,
updated_at = EXCLUDED.updated_at",
)
.bind(media_id)
.bind(&sha256)
.bind(content_id)
.bind(now_iso())
.execute(&mut *tx)
.await?;
if let Some(track_id) = sqlx::query_scalar::<_, i64>(
"SELECT id FROM furumusic__track WHERE audio_file_id = $1 LIMIT 1",
)
.bind(media_id)
.fetch_optional(&mut *tx)
.await?
{
tx.commit().await?;
return Ok(track_id);
}
let release_title = nonempty(&downloaded.metadata.release_title).unwrap_or("Unknown release");
let release_sort = music_dht::normalize_name(release_title);
let release_id: i64 = if let Some(id) = sqlx::query_scalar(
"SELECT id FROM furumusic__release
WHERE title_sort = $1 AND year IS NOT DISTINCT FROM $2
ORDER BY id LIMIT 1",
)
.bind(&release_sort)
.bind(downloaded.metadata.year)
.fetch_optional(&mut *tx)
.await?
{
id
} else {
sqlx::query_scalar(
"INSERT INTO furumusic__release
(title, title_sort, release_type, year, is_hidden, model_name,
created_at, updated_at)
VALUES ($1, $2, $3, $4, false, NULL, $5, $5)
RETURNING id",
)
.bind(release_title)
.bind(&release_sort)
.bind(
downloaded
.metadata
.release_type
.as_deref()
.unwrap_or("album"),
)
.bind(downloaded.metadata.year)
.bind(now_iso())
.fetch_one(&mut *tx)
.await?
};
let duration: f64 = sqlx::query_scalar(
"SELECT COALESCE(duration_seconds, 0)
FROM furumusic__track_ref WHERE content_id = $1",
)
.bind(content_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
let track_id: i64 = sqlx::query_scalar(
"INSERT INTO furumusic__track
(title, title_sort, release_id, track_number, disc_number,
duration_seconds, audio_file_id, year, is_hidden, model_name,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, NULL, $9, $9)
RETURNING id",
)
.bind(&downloaded.metadata.title)
.bind(music_dht::normalize_name(&downloaded.metadata.title))
.bind(release_id)
.bind(downloaded.metadata.track_number)
.bind(downloaded.metadata.disc_number)
.bind(duration)
.bind(media_id)
.bind(downloaded.metadata.year)
.bind(now_iso())
.fetch_one(&mut *tx)
.await?;
let main_artists = if downloaded.metadata.artists.is_empty() {
&downloaded.metadata.album_artists
} else {
&downloaded.metadata.artists
};
let mut main_artist_ids = Vec::new();
for (position, name) in main_artists.iter().enumerate() {
let artist_id = ensure_artist(&mut tx, name).await?;
main_artist_ids.push(artist_id);
link_track_artist(&mut tx, track_id, artist_id, "main", position as i32).await?;
link_release_artist(&mut tx, release_id, artist_id, position as i32).await?;
}
for (position, name) in downloaded.metadata.featured_artists.iter().enumerate() {
let artist_id = ensure_artist(&mut tx, name).await?;
link_track_artist(&mut tx, track_id, artist_id, "featuring", position as i32).await?;
}
sqlx::query(
"UPDATE furumusic__track_ref
SET local_track_id = $2, metadata_authority = 'federation', updated_at = $3
WHERE content_id = $1",
)
.bind(content_id)
.bind(track_id)
.bind(now_iso())
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE furumusic__fed_state_like
SET local_track_id = $2 WHERE content_id = $1",
)
.bind(content_id)
.bind(track_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE furumusic__fed_state_playlist_item
SET local_track_id = $2 WHERE content_id = $1",
)
.bind(content_id)
.bind(track_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
super::devices::materialize_content_state(pool, content_id, track_id).await?;
// Artwork is non-authoritative for identity and may be installed after
// the audio transaction. Failure does not invalidate a verified track.
if let Some((bytes, mime)) = downloaded.cover
&& let Err(err) =
install_release_artwork(pool, storage_root, release_id, &bytes, &mime).await
{
tracing::warn!(
release_id,
"failed to install federated release artwork: {err:#}"
);
}
if let Some((bytes, mime)) = downloaded.artist_image
&& let Err(err) =
install_artist_artwork(pool, storage_root, &main_artist_ids, &bytes, &mime).await
{
tracing::warn!("failed to install federated artist artwork: {err:#}");
}
Ok(track_id)
}
async fn install_release_artwork(
pool: &sqlx::PgPool,
storage_root: &Path,
release_id: i64,
bytes: &[u8],
mime: &str,
) -> Result<()> {
let media_id = persist_artwork(pool, storage_root, bytes, mime).await?;
sqlx::query(
"UPDATE furumusic__release
SET cover_file_id = $1, updated_at = $3
WHERE id = $2 AND cover_file_id IS NULL",
)
.bind(media_id)
.bind(release_id)
.bind(now_iso())
.execute(pool)
.await?;
Ok(())
}
async fn install_artist_artwork(
pool: &sqlx::PgPool,
storage_root: &Path,
artist_ids: &[i64],
bytes: &[u8],
mime: &str,
) -> Result<()> {
if artist_ids.is_empty() {
return Ok(());
}
let media_id = persist_artwork(pool, storage_root, bytes, mime).await?;
sqlx::query(
"UPDATE furumusic__artist
SET image_file_id = $1, updated_at = $3
WHERE id = ANY($2) AND image_file_id IS NULL",
)
.bind(media_id)
.bind(artist_ids)
.bind(now_iso())
.execute(pool)
.await?;
Ok(())
}
async fn persist_artwork(
pool: &sqlx::PgPool,
storage_root: &Path,
bytes: &[u8],
mime: &str,
) -> Result<i64> {
anyhow::ensure!(!bytes.is_empty() && bytes.len() as u64 <= MAX_IMAGE_BYTES);
anyhow::ensure!(mime.starts_with("image/"), "invalid artwork mime type");
let hash = format!("{:x}", Sha256::digest(bytes));
if let Some(id) = sqlx::query_scalar(
"SELECT id FROM furumusic__media_file
WHERE file_type = 'cover_art' AND sha256_hash = $1 LIMIT 1",
)
.bind(&hash)
.fetch_optional(pool)
.await?
{
return Ok(id);
}
let extension = image_extension(mime);
let filename = format!("federation-artwork-{}.{}", &hash[..16], extension);
let dir = storage_root.join("federation").join("artwork");
tokio::fs::create_dir_all(&dir).await?;
let path = dir.join(&filename);
tokio::fs::write(&path, bytes).await?;
let relative = path
.strip_prefix(storage_root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let id = sqlx::query_scalar(
"INSERT INTO furumusic__media_file
(file_type, file_path, original_filename, mime_type,
file_size_bytes, sha256_hash, uploaded_by_user_id,
uploader_name, created_at)
VALUES ('cover_art', $1, $2, $3, $4, $5, NULL, 'Federation', $6)
RETURNING id",
)
.bind(&relative)
.bind(&filename)
.bind(mime)
.bind(bytes.len() as i64)
.bind(&hash)
.bind(now_iso())
.fetch_one(pool)
.await?;
if let Err(err) = crate::agent::cover_variants::ensure_cover_variants(&path).await {
tracing::warn!(
media_id = id,
"failed to generate federated artwork variants: {err}"
);
}
Ok(id)
}
fn image_extension(mime: &str) -> &'static str {
match mime {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/avif" => "avif",
_ => "jpg",
}
}
async fn ensure_artist(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, name: &str) -> Result<i64> {
let normalized = music_dht::normalize_name(name);
if let Some(id) = sqlx::query_scalar(
"SELECT id FROM furumusic__artist WHERE name_sort = $1 ORDER BY id LIMIT 1",
)
.bind(&normalized)
.fetch_optional(&mut **tx)
.await?
{
return Ok(id);
}
Ok(sqlx::query_scalar(
"INSERT INTO furumusic__artist
(name, name_sort, is_hidden, model_name, created_at, updated_at)
VALUES ($1, $2, false, NULL, $3, $3) RETURNING id",
)
.bind(name)
.bind(normalized)
.bind(now_iso())
.fetch_one(&mut **tx)
.await?)
}
async fn link_track_artist(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
track_id: i64,
artist_id: i64,
role: &str,
position: i32,
) -> Result<()> {
sqlx::query(
"INSERT INTO furumusic__track_artist
(track_id, artist_id, role, position) VALUES ($1, $2, $3, $4)",
)
.bind(track_id)
.bind(artist_id)
.bind(role)
.bind(position)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn link_release_artist(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
release_id: i64,
artist_id: i64,
position: i32,
) -> Result<()> {
sqlx::query(
"INSERT INTO furumusic__release_artist
(release_id, artist_id, position)
SELECT $1, $2, $3 WHERE NOT EXISTS (
SELECT 1 FROM furumusic__release_artist
WHERE release_id = $1 AND artist_id = $2
)",
)
.bind(release_id)
.bind(artist_id)
.bind(position)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn local_track_id(pool: &sqlx::PgPool, content_id: &str) -> Result<Option<i64>> {
Ok(sqlx::query_scalar(
"SELECT t.id
FROM furumusic__federation_content_id_cache c
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
WHERE c.content_id = $1 AND t.is_hidden = false LIMIT 1",
)
.bind(content_id)
.fetch_optional(pool)
.await?)
}
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
let mut line = Vec::new();
let mut byte = [0u8; 1];
loop {
anyhow::ensure!(
reader.read_exact(&mut byte).await.is_ok(),
"stream ended early"
);
if byte[0] == b'\n' {
return Ok(line);
}
line.push(byte[0]);
anyhow::ensure!(line.len() <= MAX_LINE, "protocol line too large");
}
}
async fn write_line<W: AsyncWrite + 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(())
}
async fn read_segment<R: AsyncRead + Unpin>(
reader: &mut R,
size: u64,
mime: &str,
label: &str,
) -> Result<Option<(Vec<u8>, String)>> {
if size == 0 {
return Ok(None);
}
anyhow::ensure!(size <= MAX_IMAGE_BYTES, "{label} is too large");
let mut bytes = vec![0u8; size as usize];
reader.read_exact(&mut bytes).await?;
Ok(Some((bytes, mime.to_owned())))
}
fn audio_extension(mime: &str) -> &'static str {
match mime {
"audio/mpeg" => "mp3",
"audio/flac" => "flac",
"audio/ogg" => "ogg",
"audio/opus" => "opus",
"audio/wav" => "wav",
"audio/mp4" => "m4a",
"audio/aac" => "aac",
_ => "bin",
}
}
fn nonempty(value: &str) -> Option<&str> {
(!value.trim().is_empty()).then_some(value.trim())
}
+773
View File
@@ -0,0 +1,773 @@
//! 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 std::sync::Arc;
use anyhow::Result;
pub use music_dht::catalog::CATALOG_ALPN;
use music_dht::catalog::{
CatalogArtist, CatalogArtistPreview, CatalogImageHeader as ImageHeader, CatalogRelease,
CatalogRequest, CatalogResponse, CatalogTrack,
};
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor, normalize_name};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use sqlx::Row as _;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use super::{TransportStats, record_stream_transport};
/// ALPN of the peer-to-peer audio streaming protocol.
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/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>,
}
// ---------------------------------------------------------------------------
// 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_catalog_artist_names(
pool: &PgPool,
track_id: i64,
) -> Result<(Vec<String>, Vec<String>)> {
let mut artists = Vec::new();
let mut featured = Vec::new();
let 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 rows {
let name: String = row.get(0);
match row.get::<String, _>(1).as_str() {
"featuring" => featured.push(name),
"main" => artists.push(name),
_ => {}
}
}
Ok((artists, featured))
}
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,
transport_stats: Arc<TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let pool = pool.clone();
let storage_dir = storage_dir.clone();
let transport_stats = Arc::clone(&transport_stats);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).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,
transport_stats: Arc<TransportStats>,
) -> Result<()> {
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
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;
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
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,
transport_stats: Arc<TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let pool = pool.clone();
let storage_dir = storage_dir.clone();
let transport_stats = Arc::clone(&transport_stats);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) =
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).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,
transport_stats: Arc<TransportStats>,
) -> Result<()> {
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
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:#}")),
..CatalogResponse::default()
},
};
stream
.send
.write_all(&serde_json::to_vec(&response)?)
.await?;
}
Some("artists") => {
let cursor = request.cursor.clone();
let limit = request.limit.unwrap_or(64).clamp(1, 200);
let response = match build_artist_slice(&pool, cursor, limit).await {
Ok(response) => response,
Err(err) => CatalogResponse {
ok: false,
error: Some(format!("artist slice lookup failed: {err:#}")),
..CatalogResponse::default()
},
};
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}'")),
..CatalogResponse::default()
};
stream
.send
.write_all(&serde_json::to_vec(&response)?)
.await?;
}
}
stream.send.finish()?;
let _ = stream.send.stopped().await;
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
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()),
..CatalogResponse::default()
});
};
let artist_id: i64 = artist_row.get(0);
let release_rows = sqlx::query(
"SELECT r.id, r.title, r.release_type, r.year
FROM furumusic__release r
JOIN furumusic__release_artist ra ON ra.release_id = r.id
WHERE ra.artist_id = $1 AND r.is_hidden = false
ORDER BY r.year NULLS LAST, r.title",
)
.bind(artist_id)
.fetch_all(pool)
.await?;
let mut releases = Vec::new();
for release_row in release_rows {
let release_id: i64 = release_row.get(0);
let track_rows = sqlx::query(
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
c.content_id
FROM furumusic__track t
JOIN furumusic__media_file m ON m.id = t.audio_file_id
LEFT JOIN furumusic__federation_content_id_cache c
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
WHERE t.release_id = $1 AND t.is_hidden = false
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
)
.bind(release_id)
.fetch_all(pool)
.await?;
let mut tracks = Vec::with_capacity(track_rows.len());
for row in track_rows {
let track_id: i64 = row.get(0);
let duration: f64 = row.get(4);
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
tracks.push(CatalogTrack {
title: row.get(1),
artists,
featured_artists,
track_number: row.get(2),
disc_number: row.get(3),
duration_seconds: (duration > 0.0).then_some(duration),
content_id: row.get(5),
item_id: item_id_of(own, track_id),
});
}
releases.push(CatalogRelease {
title: release_row.get(1),
release_type: release_row.get(2),
year: release_row.get(3),
tracks,
});
}
Ok(CatalogResponse {
ok: true,
artist: Some(CatalogArtist {
name: artist_row.get(1),
releases,
appears_on: Vec::new(),
}),
..CatalogResponse::default()
})
}
async fn build_artist_slice(
pool: &PgPool,
cursor: Option<String>,
limit: usize,
) -> Result<CatalogResponse> {
let offset = cursor
.as_deref()
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0)
.max(0);
let rows = sqlx::query(
r#"SELECT a.name::text AS name,
mf.file_path::text AS image_path,
COALESCE(s.release_count, 0)::bigint AS release_count,
COALESCE(s.track_count, 0)::bigint AS track_count
FROM furumusic__artist a
LEFT JOIN furumusic__media_file mf ON mf.id = a.image_file_id
LEFT JOIN (
SELECT appearance.artist_id,
COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count,
COUNT(DISTINCT appearance.track_id) AS track_count
FROM (
SELECT ta.artist_id,
t.id AS track_id,
r.id AS release_id,
primary_release.artist_id IS NOT NULL AS is_primary_release_artist
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
LEFT JOIN furumusic__release_artist primary_release
ON primary_release.release_id = r.id
AND primary_release.artist_id = ta.artist_id
AND primary_release.position = 0
) appearance
GROUP BY appearance.artist_id
) s ON s.artist_id = a.id
WHERE a.is_hidden = false
AND COALESCE(s.track_count, 0) > 0
ORDER BY (COALESCE(s.release_count, 0) > 0) DESC,
COALESCE(s.release_count, 0) DESC,
COALESCE(s.track_count, 0) DESC,
a.name_sort
LIMIT $1 OFFSET $2"#,
)
.bind(limit as i64 + 1)
.bind(offset)
.fetch_all(pool)
.await?;
let mut artists = Vec::with_capacity(rows.len().min(limit));
let has_more = rows.len() > limit;
for row in rows.into_iter().take(limit) {
let name: String = row.get(0);
artists.push(CatalogArtistPreview {
artist_key: normalize_name(&name),
name,
image_path: row.get(1),
release_count: row.get(2),
track_count: row.get(3),
});
}
let next_cursor = has_more.then(|| (offset + artists.len() as i64).to_string());
Ok(CatalogResponse {
ok: true,
artists,
next_cursor,
..CatalogResponse::default()
})
}
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))))
}
+349
View File
@@ -0,0 +1,349 @@
use std::str::FromStr;
use async_trait::async_trait;
use music_dht::{
DhtKey, EndpointId, LibraryItem, MAX_RECORDS_PER_RESPONSE, MusicDhtError, MusicDhtStorage,
NodeContact, NodeId, SecretKey, StoreDecision, StoredRecord, decide_store,
};
use sqlx::{PgPool, Row as _};
const IDENTITY_NAME: &str = "default";
const SCHEMA: &[&str] = &[
"CREATE TABLE IF NOT EXISTS furumusic__federation_identity (
name TEXT PRIMARY KEY,
secret_key BYTEA NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS furumusic__federation_local_item (
id BYTEA PRIMARY KEY,
normalized_name TEXT NOT NULL,
revision BIGINT NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT false,
updated_at_ms BIGINT NOT NULL,
payload BYTEA NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_local_item_normalized_name
ON furumusic__federation_local_item(normalized_name)",
"CREATE TABLE IF NOT EXISTS furumusic__federation_dht_record (
dht_key BYTEA NOT NULL,
item_id BYTEA NOT NULL,
owner_peer_id TEXT NOT NULL,
payload BYTEA NOT NULL,
revision BIGINT NOT NULL,
deleted BOOLEAN NOT NULL,
expires_at_ms BIGINT NOT NULL,
PRIMARY KEY (dht_key, item_id, owner_peer_id)
)",
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_dht_record_expires_at
ON furumusic__federation_dht_record(expires_at_ms)",
"CREATE TABLE IF NOT EXISTS furumusic__federation_known_peer (
peer_id TEXT PRIMARY KEY,
node_id BYTEA NOT NULL,
ticket TEXT NOT NULL,
last_seen_ms BIGINT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
media_file_id BIGINT PRIMARY KEY,
sha256_hash TEXT NOT NULL,
content_id TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
ON furumusic__federation_content_id_cache(content_id)",
];
#[derive(Debug, Clone)]
pub struct PostgresFederationStorage {
pool: PgPool,
}
impl PostgresFederationStorage {
pub async fn new(pool: PgPool) -> music_dht::Result<Self> {
let storage = Self { pool };
storage.ensure_schema().await?;
Ok(storage)
}
pub async fn load_or_create_secret_key(&self) -> music_dht::Result<SecretKey> {
if let Some(bytes) = sqlx::query_scalar::<_, Vec<u8>>(
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
)
.bind(IDENTITY_NAME)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?
{
return secret_from_bytes(bytes);
}
let key = SecretKey::generate();
let key_bytes = key.to_bytes();
let now = now_iso();
let inserted = sqlx::query(
"INSERT INTO furumusic__federation_identity
(name, secret_key, created_at, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO NOTHING",
)
.bind(IDENTITY_NAME)
.bind(key_bytes.as_slice())
.bind(&now)
.bind(&now)
.execute(&self.pool)
.await
.map_err(db_error)?
.rows_affected();
if inserted == 1 {
return Ok(key);
}
let bytes = sqlx::query_scalar::<_, Vec<u8>>(
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
)
.bind(IDENTITY_NAME)
.fetch_one(&self.pool)
.await
.map_err(db_error)?;
secret_from_bytes(bytes)
}
async fn ensure_schema(&self) -> music_dht::Result<()> {
for sql in SCHEMA {
sqlx::query(sql)
.execute(&self.pool)
.await
.map_err(db_error)?;
}
Ok(())
}
}
#[async_trait]
impl MusicDhtStorage for PostgresFederationStorage {
async fn upsert_local_item(&self, item: &LibraryItem) -> music_dht::Result<()> {
let payload = postcard::to_stdvec(item).map_err(db_error)?;
sqlx::query(
"INSERT INTO furumusic__federation_local_item
(id, normalized_name, revision, deleted, updated_at_ms, payload)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
normalized_name = EXCLUDED.normalized_name,
revision = EXCLUDED.revision,
deleted = EXCLUDED.deleted,
updated_at_ms = EXCLUDED.updated_at_ms,
payload = EXCLUDED.payload",
)
.bind(item.id.as_bytes().as_slice())
.bind(&item.normalized_name)
.bind(item.revision as i64)
.bind(item.deleted)
.bind(item.updated_at_ms as i64)
.bind(payload)
.execute(&self.pool)
.await
.map_err(db_error)?;
Ok(())
}
async fn list_local_items(&self, include_deleted: bool) -> music_dht::Result<Vec<LibraryItem>> {
let rows = sqlx::query(
"SELECT payload
FROM furumusic__federation_local_item
WHERE $1 OR deleted = false
ORDER BY normalized_name",
)
.bind(include_deleted)
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
Ok(rows
.into_iter()
.filter_map(|row| postcard::from_bytes::<LibraryItem>(&row.get::<Vec<u8>, _>(0)).ok())
.collect())
}
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
let mut conn = self.pool.acquire().await.map_err(db_error)?;
store_record_in_conn(&mut conn, &key, &record).await
}
async fn store_dht_records(
&self,
entries: Vec<(DhtKey, StoredRecord)>,
) -> music_dht::Result<Vec<bool>> {
let mut tx = self.pool.begin().await.map_err(db_error)?;
let mut stored = Vec::with_capacity(entries.len());
for (key, record) in &entries {
stored.push(store_record_in_conn(&mut tx, key, record).await?);
}
tx.commit().await.map_err(db_error)?;
Ok(stored)
}
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))
}
/// Applies one validated record following the revision/tombstone rules.
/// Returns `true` if the record was written or refreshed. Runs against a
/// pooled connection or an open transaction.
async fn store_record_in_conn(
conn: &mut sqlx::PgConnection,
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(&mut *conn)
.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(&mut *conn)
.await
.map_err(db_error)?;
Ok(true)
}
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
MusicDhtError::Database(err.to_string())
}
+1
View File
@@ -309,6 +309,7 @@ translations! {
player_cancel: "Cancel" , "Отмена";
player_create: "Create" , "Создать";
player_save: "Save" , "Сохранить";
player_done: "Done" , "Готово";
player_delete: "Delete" , "Удалить";
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
player_rename: "Rename" , "Переименовать";
+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",
];
/// How long a `failed` review must stay untouched before discover
/// automatically requeues it (instead of creating a new row per attempt).
const FAILED_RETRY_COOLDOWN_SECS: i64 = 3600;
/// Leftover files that are safe to purge from inbox folders that no longer
/// contain any audio (covers, playlists, rip logs and similar sidecar files).
const JUNK_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "bmp", "m3u", "m3u8", "cue", "log", "txt", "nfo", "sfv",
"md5", "accurip", "url", "ini", "pdf",
];
const JUNK_FILENAMES: &[&str] = &[".ds_store", "thumbs.db", "desktop.ini"];
/// Junk younger than this is kept — an upload might still be in progress.
const JUNK_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
pub struct InboxDiscoverJob;
#[async_trait::async_trait]
@@ -76,6 +91,10 @@ impl Job for InboxDiscoverJob {
let mut audio_files = Vec::new();
collect_audio_files(inbox, &mut audio_files).await?;
// Purge leftover junk (covers, playlists, logs) from subtrees that no
// longer contain audio, so processed uploads don't linger forever.
cleanup_inbox_junk(inbox, JUNK_MIN_AGE).await;
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
if audio_files.is_empty() {
return Ok(());
@@ -87,6 +106,7 @@ impl Job for InboxDiscoverJob {
let mut discovered = 0u64;
let mut skipped_hash = 0u64;
let mut skipped_existing = 0u64;
let mut requeued = 0u64;
for (_folder, files) in &groups {
for file_path in files {
@@ -94,13 +114,34 @@ impl Job for InboxDiscoverJob {
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
// Skip if a PendingReview already exists for this path
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
Ok(true) => {
skipped_existing += 1;
// One review row per path: any existing row blocks creating a
// new one. A stale "failed" row is requeued in place instead,
// so retries don't multiply rows. "rejected" stays rejected.
match PendingReview::latest_for_path(&ctx.pool, &input_path_str).await {
Ok(None) => {}
Ok(Some((id, status, updated_at))) => {
if status == "failed" {
let stale = chrono::DateTime::parse_from_rfc3339(&updated_at)
.map(|t| {
chrono::Utc::now().signed_duration_since(t).num_seconds()
>= FAILED_RETRY_COOLDOWN_SECS
})
.unwrap_or(true);
if stale {
match PendingReview::requeue_by_ids(&ctx.db, &[id]).await {
Ok(()) => requeued += 1,
Err(e) => log.warn(&format!(
"Failed to requeue review {id} for {input_path_str}: {e}"
)),
}
} else {
skipped_existing += 1;
}
} else {
skipped_existing += 1;
}
continue;
}
Ok(false) => {}
Err(e) => {
log.warn(&format!(
"Error checking existing review for {}: {e}",
@@ -215,8 +256,8 @@ impl Job for InboxDiscoverJob {
}
log.info(&format!(
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
discovered, skipped_hash, skipped_existing
"Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
discovered, requeued, skipped_hash, skipped_existing
));
crate::metrics::record_agent_discover_files(
audio_files.len() as u64,
@@ -227,7 +268,7 @@ impl Job for InboxDiscoverJob {
// Trigger inbox_process in background if new files were discovered
// and no orchestrator is already running
if discovered > 0 {
if discovered + requeued > 0 {
if crate::jobs::inbox_process::is_orchestrator_running() {
log.info(
"New files discovered but inbox_process already running, it will pick them up",
@@ -299,3 +340,75 @@ pub fn is_audio_file(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
AUDIO_EXTENSIONS.contains(&ext.as_str())
}
fn is_junk_file(name: &str) -> bool {
let lower = name.to_lowercase();
// macOS AppleDouble sidecars ("._track.mp3") and well-known junk names
if lower.starts_with("._") || JUNK_FILENAMES.contains(&lower.as_str()) {
return true;
}
let ext = lower.rsplit('.').next().unwrap_or("");
JUNK_EXTENSIONS.contains(&ext)
}
/// Remove leftover sidecar files (covers, playlists, rip logs) from inbox
/// subtrees that no longer contain any audio, then prune emptied directories.
///
/// Junk younger than `min_age` is kept in case an upload is still in
/// progress, and unknown file types are never touched. Returns `true` when
/// `dir` still contains something worth keeping (so the caller must not
/// remove it).
async fn cleanup_inbox_junk(dir: &Path, min_age: std::time::Duration) -> bool {
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(e) => e,
Err(_) => return true,
};
let mut has_audio = false;
let mut keep_other = false;
let mut junk: Vec<PathBuf> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().into_owned();
let ft = match entry.file_type().await {
Ok(ft) => ft,
Err(_) => {
keep_other = true;
continue;
}
};
if ft.is_dir() {
if Box::pin(cleanup_inbox_junk(&entry.path(), min_age)).await {
keep_other = true;
} else {
let _ = tokio::fs::remove_dir(&entry.path()).await;
}
} else if !name.starts_with('.') && is_audio_file(&name) {
// dotfiles are invisible to discovery, so they don't count as audio
has_audio = true;
} else if is_junk_file(&name) {
junk.push(entry.path());
} else {
keep_other = true;
}
}
if has_audio {
return true;
}
let mut junk_left = false;
for path in junk {
let old_enough = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age >= min_age);
if !old_enough || tokio::fs::remove_file(&path).await.is_err() {
junk_left = true;
}
}
keep_other || junk_left
}
+66 -14
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.
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
/// Maximum number of files sent to the LLM in a single batch call.
/// Folders with more files are processed in chunks of this size, otherwise
/// the model's completion window overflows and the JSON response is cut off.
const MAX_LLM_BATCH_FILES: usize = 20;
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
@@ -214,14 +219,25 @@ impl Job for InboxProcessJob {
folder_rel, file_count,
));
let (ok, fail) =
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
.await;
// Large folders are split into chunks: a single LLM call for
// 100+ files overflows the completion window and the whole
// batch fails with a truncated-JSON parse error.
for chunk in reviews.chunks(MAX_LLM_BATCH_FILES) {
let (ok, fail) = process_folder_batch(
&ctx.db,
&config,
&ctx.pool,
&folder_rel,
chunk.to_vec(),
log,
)
.await;
total_ok += ok;
total_fail += fail;
total_ok += ok;
total_fail += fail;
}
log.info(&format!(
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
"Folder done. Total so far: {total_ok} ok, {total_fail} err"
));
}
}
@@ -344,6 +360,7 @@ async fn process_folder_batch(
log.info("Phase 1: extracting metadata...");
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
let mut failed_reviews: Vec<PendingReview> = Vec::new();
let mut merged_count = 0u64;
for mut review in reviews {
let stored_input_path = review.input_path_str().to_owned();
@@ -355,9 +372,6 @@ async fn process_folder_batch(
.unwrap_or("unknown")
.to_owned();
// Set status → processing
let _ = review.set_processing(db).await;
// Parse context_json
let mut context: serde_json::Value = review
.context_json
@@ -365,6 +379,42 @@ async fn process_folder_batch(
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
// Resolve duplicates and missing sources before any expensive work.
let sha = context
.get("sha256")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let file_exists = file_path.exists();
if !sha.is_empty()
&& crate::agent::rag::file_hash_exists(pool, &sha)
.await
.unwrap_or(false)
{
// Identical content is already in the library — drop the inbox
// copy (same as mover::Merged) and close the review.
if file_exists {
let _ = tokio::fs::remove_file(&file_path).await;
}
let _ = PendingReview::delete_by_ids(db, &[review.id_val()]).await;
log.info(&format!(
"{filename}: content already in library (sha256 match) — merged duplicate"
));
crate::metrics::record_agent_file_processed("ok", "merged_duplicate");
merged_count += 1;
continue;
}
if !file_exists {
let msg = format!("{filename}: source file missing: {stored_input_path}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
// Set status → processing
let _ = review.set_processing(db).await;
// Extract metadata (with 60s timeout)
let path_for_meta = file_path.to_path_buf();
let metadata_start = std::time::Instant::now();
@@ -444,15 +494,16 @@ async fn process_folder_batch(
}
log.info(&format!(
"Phase 1 done: {} prepared, {} failed metadata",
"Phase 1 done: {} prepared, {} merged duplicates, {} failed",
prepared.len(),
merged_count,
failed_reviews.len(),
));
if prepared.is_empty() {
let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run.set_completed(db, duration_ms, &log.output()).await;
return (0, failed_reviews.len() as u64);
return (merged_count, failed_reviews.len() as u64);
}
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
@@ -648,16 +699,17 @@ async fn process_folder_batch(
let err_msg = format!("Batch LLM call failed: {e}");
log.error(&err_msg);
// Mark all files as failed
let prepared_count = prepared.len() as u64;
for mut p in prepared {
let _ = p.review.set_failed(db, &err_msg).await;
crate::metrics::record_agent_file_processed("failed", "failed");
}
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
let total_fail_count = failed_reviews.len() as u64 + prepared_count;
let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run
.set_failed(db, duration_ms, &log.output(), &err_msg)
.await;
return (0, total_fail_count);
return (merged_count, total_fail_count);
}
};
@@ -681,7 +733,7 @@ async fn process_folder_batch(
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
let mut ok_count = 0u64;
let mut ok_count = merged_count;
let mut fail_count = failed_reviews.len() as u64;
for mut p in prepared {
+8
View File
@@ -3,6 +3,7 @@ mod agent;
mod api;
mod auth;
mod config;
mod federation;
mod i18n;
mod jobs;
mod lastfm;
@@ -559,6 +560,13 @@ impl Project for FuruProject {
.await;
});
// Join the federation at boot when it was left enabled (the settings
// live in the config KV table; changes apply live from the admin).
let fed_config = Arc::clone(&self.app_config);
tokio::spawn(async move {
federation::handle().boot(&fed_config).await;
});
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
FuruApp {
+1
View File
@@ -883,6 +883,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/api/player/lastfm/now-playing",
"/api/player/lastfm/scrobble",
"/api/player/agent-queue",
"/api/player/offline/manifest",
"/api/player/torrents",
"/api/player/torrents/session/{id}",
"/api/player/torrents/preview",
+374
View File
@@ -1951,6 +1951,377 @@ pub mod db_migrations {
&[Operation::custom(create_playlist_share_links).build()];
}
#[cot::db::migrations::migration_op]
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
media_file_id BIGINT PRIMARY KEY,
sha256_hash TEXT NOT NULL,
content_id TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
ON furumusic__federation_content_id_cache (content_id)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
user_id BIGINT PRIMARY KEY,
device_id TEXT NOT NULL UNIQUE,
group_id TEXT NOT NULL,
device_name TEXT NOT NULL,
local_seq BIGINT NOT NULL DEFAULT 0,
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
last_sync TEXT,
last_error TEXT
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
user_id BIGINT NOT NULL,
device_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
protocol_version INTEGER NOT NULL DEFAULT 1,
endpoint_id TEXT NOT NULL DEFAULT '',
endpoint_ticket TEXT NOT NULL DEFAULT '',
trusted_at_ms BIGINT,
last_seen_ms BIGINT,
revoked_at_ms BIGINT,
revoked_by TEXT,
revoke_cutoff_seq BIGINT,
PRIMARY KEY (user_id, device_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
ON furumusic__fed_device (device_id)
WHERE trusted_at_ms IS NOT NULL",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
invite_id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
secret_hash TEXT NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
used_at_ms BIGINT
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
request_id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
device_id TEXT NOT NULL,
name TEXT NOT NULL,
client_version TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
endpoint_ticket TEXT NOT NULL,
invite_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
answered_at_ms BIGINT,
status TEXT NOT NULL,
requester_group_id TEXT,
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
use_requester_group BOOLEAN NOT NULL DEFAULT false
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
user_id BIGINT NOT NULL,
op_id TEXT NOT NULL,
origin_device_id TEXT NOT NULL,
seq BIGINT NOT NULL,
kind TEXT NOT NULL,
payload_json JSONB NOT NULL,
hlc_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
tombstone BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_id, op_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
ON furumusic__fed_sync_ops (user_id, tombstone)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
user_id BIGINT NOT NULL,
device_id TEXT NOT NULL,
max_seq BIGINT NOT NULL,
PRIMARY KEY (user_id, device_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
user_id BIGINT NOT NULL,
peer_device_id TEXT NOT NULL,
origin_device_id TEXT NOT NULL,
max_seq BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
user_id BIGINT NOT NULL,
content_id TEXT NOT NULL,
liked BOOLEAN NOT NULL,
hlc_ms BIGINT NOT NULL,
op_id TEXT NOT NULL,
local_track_id BIGINT,
fed_json JSONB,
PRIMARY KEY (user_id, content_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
user_id BIGINT NOT NULL,
playlist_id TEXT NOT NULL,
local_playlist_id BIGINT,
title TEXT NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT false,
hlc_ms BIGINT NOT NULL,
op_id TEXT NOT NULL,
PRIMARY KEY (user_id, playlist_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
WHERE local_playlist_id IS NOT NULL",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
user_id BIGINT NOT NULL,
playlist_id TEXT NOT NULL,
content_id TEXT NOT NULL,
present BOOLEAN NOT NULL DEFAULT true,
position BIGINT NOT NULL DEFAULT 0,
hlc_ms BIGINT NOT NULL,
op_id TEXT NOT NULL,
local_track_id BIGINT,
fed_json JSONB,
PRIMARY KEY (user_id, playlist_id, content_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
ON furumusic__fed_state_playlist_item
(user_id, playlist_id, present, position)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
user_id BIGINT NOT NULL,
op_id TEXT NOT NULL,
applied_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, op_id)
)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0038CreateFedDeviceSync;
impl migrations::Migration for M0038CreateFedDeviceSync {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0037_create_playlist_share_links",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_fed_device_sync).build()];
}
#[cot::db::migrations::migration_op]
async fn ensure_federation_content_id_cache(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
media_file_id BIGINT PRIMARY KEY,
sha256_hash TEXT NOT NULL,
content_id TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
ON furumusic__federation_content_id_cache (content_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0039EnsureFederationContentIdCache;
impl migrations::Migration for M0039EnsureFederationContentIdCache {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0038_create_fed_device_sync",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(ensure_federation_content_id_cache).build()];
}
#[cot::db::migrations::migration_op]
async fn create_content_addressed_music_refs(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
// A track reference is durable user-facing identity. `local_track_id`
// is availability, not identity: it may become non-NULL after a
// federated track is materialized without changing likes/playlists.
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__track_ref (
id BIGSERIAL PRIMARY KEY,
content_id TEXT NOT NULL UNIQUE,
local_track_id BIGINT UNIQUE,
title TEXT NOT NULL,
release_title TEXT,
year INTEGER,
duration_seconds DOUBLE PRECISION,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata_authority TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_track_ref_local_track
ON furumusic__track_ref (local_track_id)
WHERE local_track_id IS NOT NULL",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__federation_track_source (
track_ref_id BIGINT NOT NULL REFERENCES furumusic__track_ref(id)
ON DELETE CASCADE,
owner_peer_id TEXT NOT NULL,
item_id TEXT NOT NULL,
last_seen_ms BIGINT NOT NULL,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (owner_peer_id, item_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_federation_track_source_ref
ON furumusic__federation_track_source (track_ref_id, last_seen_ms DESC)",
)
.await?;
ctx.db
.raw(
"ALTER TABLE furumusic__user_liked_track
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
REFERENCES furumusic__track_ref(id)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_liked_track_ref_uniq
ON furumusic__user_liked_track (user_id, track_ref_id)
WHERE track_ref_id IS NOT NULL",
)
.await?;
ctx.db
.raw(
"ALTER TABLE furumusic__playlist_track
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
REFERENCES furumusic__track_ref(id)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_playlist_track_ref
ON furumusic__playlist_track (track_ref_id)
WHERE track_ref_id IS NOT NULL",
)
.await?;
// History deliberately remains local-track based. Only the web
// player's existing playback report records history and triggers
// Last.fm scrobbling.
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0040CreateContentAddressedMusicRefs;
impl migrations::Migration for M0040CreateContentAddressedMusicRefs {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0040_create_content_addressed_music_refs";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0039_ensure_federation_content_id_cache",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_content_addressed_music_refs).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1979,5 +2350,8 @@ pub mod db_migrations {
&M0035CreateEntityGenreTags,
&M0036CreateExternalMetadataIds,
&M0037CreatePlaylistShareLinks,
&M0038CreateFedDeviceSync,
&M0039EnsureFederationContentIdCache,
&M0040CreateContentAddressedMusicRefs,
];
}
+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://") {
return Some(value.to_owned());
}
if is_loopback_http_redirect(&lower) {
return Some(value.to_owned());
}
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 {
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(
"success",
"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,
Some(code),
&deep_link,
)
}
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
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(
"error",
"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),
None,
&deep_link,
)
}
@@ -1007,6 +1034,7 @@ fn mobile_deep_link_page(
title: &str,
message: &str,
detail: Option<&str>,
code: Option<&str>,
deep_link: &str,
) -> cot::response::Response {
let state_class = html_escape(state);
@@ -1015,6 +1043,15 @@ fn mobile_deep_link_page(
let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.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_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
@@ -1095,6 +1132,19 @@ fn mobile_deep_link_page(
font-size: 13px;
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>
</head>
<body>
@@ -1105,15 +1155,13 @@ fn mobile_deep_link_page(
{detail_html}
<a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p>
{code_html}
</main>
<script>
const deepLink = {deep_link_js};
window.setTimeout(() => {{
window.location.href = deepLink;
}}, 100);
window.setTimeout(() => {{
window.close();
}}, 1800);
</script>
</body>
</html>"#,
@@ -1230,4 +1278,25 @@ mod tests {
);
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());
}
}
+108 -1
View File
@@ -51,6 +51,7 @@ pub(super) struct ArtistRef {
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct TrackItem {
pub(super) id: i64,
pub(super) content_id: Option<String>,
pub(super) title: String,
pub(super) track_number: Option<i32>,
pub(super) disc_number: Option<i32>,
@@ -74,6 +75,15 @@ pub(super) struct TrackItem {
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlaylistTrackItem {
pub(super) playlist_track_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) sort_key: Option<i64>,
#[serde(flatten)]
pub(super) track: TrackItem,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct ArtistAppearanceTrack {
pub(super) id: i64,
@@ -265,6 +275,24 @@ pub(super) struct PlayerDevicesResponse {
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FedDeviceConnectRequest {
pub(super) invite: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FedDevicePairingAnswerRequest {
pub(super) request_id: String,
pub(super) accept: bool,
#[serde(default)]
pub(super) use_requester_group: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FedDeviceRevokeRequest {
pub(super) device_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayerDevicePollResponse {
pub(super) device_id: String,
@@ -286,7 +314,7 @@ pub(super) struct PlaylistDetail {
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) kind: String,
pub(super) tracks: Vec<TrackItem>,
pub(super) tracks: Vec<PlaylistTrackItem>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -325,6 +353,45 @@ pub(super) struct UserProfile {
pub(super) stats: UserStats,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct OfflineManifestResponse {
pub(super) generated_at: String,
pub(super) tracks: Vec<OfflineTrackManifestItem>,
pub(super) playlists: Vec<OfflinePlaylistManifestItem>,
pub(super) liked_track_ids: Vec<i64>,
pub(super) followed_artist_ids: Vec<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct OfflineTrackManifestItem {
pub(super) id: i64,
pub(super) updated_at: String,
pub(super) stream_url: String,
pub(super) audio_file_id: i64,
pub(super) audio_hash: String,
pub(super) audio_size_bytes: i64,
pub(super) audio_mime_type: String,
pub(super) audio_updated_at: String,
pub(super) cover_file_id: Option<i64>,
pub(super) cover_url: Option<String>,
pub(super) cover_hash: Option<String>,
pub(super) cover_updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct OfflinePlaylistManifestItem {
pub(super) id: i64,
pub(super) title: String,
pub(super) description: Option<String>,
pub(super) updated_at: String,
pub(super) is_own: bool,
pub(super) owner_name: Option<String>,
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) kind: String,
pub(super) track_ids: Vec<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct LastfmStatus {
pub(super) configured: bool,
@@ -497,6 +564,46 @@ pub(super) struct LikeStatus {
pub(super) liked: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct ContentTrackMutation {
pub(super) content_id: String,
pub(super) liked: Option<bool>,
pub(super) playlist_id: Option<i64>,
pub(super) position: Option<i64>,
pub(super) federation: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct PrepareFederatedTrackRequest {
pub(super) content_id: String,
pub(super) owner: String,
pub(super) item_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FederationArtworkQuery {
pub(super) owner: String,
pub(super) item_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FederationArtistQuery {
pub(super) name: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FederationCatalogArtworkQuery {
pub(super) owner: String,
pub(super) artist: String,
pub(super) release: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FederationArtworkDiscoveryQuery {
pub(super) artist: String,
pub(super) release: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct LikedIds {
pub(super) track_ids: Vec<i64>,
+1901 -63
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -42,7 +42,13 @@ pub(super) struct AddTracksRequest {
#[derive(Debug, Deserialize)]
pub(super) struct RemoveTrackRequest {
pub(super) track_id: i64,
pub(super) track_id: Option<i64>,
pub(super) playlist_track_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ReorderPlaylistRequest {
pub(super) playlist_track_ids: Vec<i64>,
}
#[derive(Debug, Deserialize)]
+30
View File
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
pub(super) file_path: String,
pub(super) mime_type: String,
pub(super) file_size_bytes: i64,
pub(super) sha256_hash: String,
pub(super) created_at: String,
}
#[derive(sqlx::FromRow)]
@@ -93,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
#[derive(sqlx::FromRow)]
pub(super) struct PlaylistTrackRow {
pub(super) playlist_track_id: Option<i64>,
pub(super) id: i64,
pub(super) title: String,
pub(super) track_number: Option<i32>,
@@ -287,3 +290,30 @@ pub(super) struct ReleaseInfoRow {
pub(super) year: Option<i32>,
pub(super) cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
pub(super) struct OfflineTrackManifestRow {
pub(super) id: i64,
pub(super) updated_at: String,
pub(super) audio_file_id: i64,
pub(super) audio_hash: String,
pub(super) audio_size_bytes: i64,
pub(super) audio_mime_type: String,
pub(super) audio_updated_at: String,
pub(super) cover_file_id: Option<i64>,
pub(super) cover_hash: Option<String>,
pub(super) cover_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct OfflinePlaylistManifestRow {
pub(super) id: i64,
pub(super) title: String,
pub(super) description: Option<String>,
pub(super) updated_at: String,
pub(super) is_own: bool,
pub(super) owner_name: String,
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) track_ids: Vec<i64>,
}
+18 -8
View File
@@ -496,14 +496,24 @@ impl PendingReview {
.await
}
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> {
let all = Self::objects().all(db).await?;
let exists = all.iter().any(|r| {
let s = r.status.as_str();
// "rejected" and "failed" reviews should not block re-discovery
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path)
});
Ok(exists)
/// Latest review row for an inbox path: `(id, status, updated_at)`.
///
/// Used by inbox_discover to decide whether a file needs a new review,
/// a requeue of its existing row, or nothing at all — without creating
/// a fresh row per retry.
pub async fn latest_for_path(
pool: &sqlx::PgPool,
path: &str,
) -> anyhow::Result<Option<(i64, String, String)>> {
let row: Option<(i64, String, String)> = sqlx::query_as(
"SELECT id, status::text, updated_at::text \
FROM furumusic__pending_review WHERE input_path = $1 \
ORDER BY id DESC LIMIT 1",
)
.bind(path)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Mark all "processing" reviews as "failed" — called at scheduler
+187 -2
View File
@@ -1613,7 +1613,7 @@ tbody tr:hover {
</button>
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
<i data-lucide="list-checks"></i>
Select filter
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
</button>
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
<i data-lucide="x"></i>
@@ -2265,6 +2265,107 @@ tbody tr:hover {
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>Federation</strong>
<span>Publish this library into the furumi P2P network</span>
</div>
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
</div>
<div class="settings-grid">
<div class="setting-toggle">
<label>
<span>Federation enabled</span>
<span class="source-pill" :class="sourceClass('federation_enabled')" x-text="settingSource('federation_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
</div>
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div>
</div>
<div class="setting-field settings-wide">
<label>
<span>Network ID (shared secret)</span>
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
</label>
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
<div class="setting-help">Every peer using the same id finds the others automatically.</div>
</div>
<div class="setting-field">
<label>
<span>Save federated tracks on play</span>
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
</div>
<div class="setting-help">Server-wide policy. Imported tracks become available to every user and are published by this peer. Federation metadata is trusted and bypasses the AI agent.</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>
<div class="probe-table" x-show="fedTransport().total_samples > 0" style="margin-top:10px">
<div class="probe-row">
<span>Transport path</span>
<strong>
<span class="badge" :class="fedPathBadge(fedTransport().last_path)" x-text="fedTransport().last_path || 'unknown'"></span>
</strong>
</div>
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().sync_samples || 0} sync`"></strong></div>
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
</div>
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
<template x-for="(sample, index) in fedTransport().last.slice(0, 5)" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
<div class="probe-row">
<span x-text="`${sample.protocol} · ${sample.direction} · ${sample.phase}`"></span>
<strong>
<span class="badge" :class="fedPathBadge(sample.selected_path)" x-text="sample.selected_path || 'unknown'"></span>
<span x-text="` ${fedRtt(sample.selected_rtt_ms)} · tx ${formatBytes(sample.total_tx_bytes || 0)} · rx ${formatBytes(sample.total_rx_bytes || 0)} · lost ${formatBytes(sample.lost_bytes || 0)}`"></span>
</strong>
</div>
</template>
</div>
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
<i data-lucide="upload-cloud"></i>
Publish now
</button>
<button class="btn" type="button" @click="fedShowTicket()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
<i data-lucide="ticket"></i>
Show ticket
</button>
</div>
<div class="setting-field settings-wide" x-show="federationTicket" style="margin-top:10px">
<label>Connection ticket (share with a peer)</label>
<textarea readonly rows="3" style="width:100%; font-family:monospace; font-size:11px" x-text="federationTicket"></textarea>
</div>
<div class="setting-field settings-wide" x-show="federationStatus.node && federationStatus.node.running" style="margin-top:10px">
<label>Connect to a peer by ticket</label>
<div style="display:flex; gap:8px">
<input x-model="fedConnectTicket" placeholder="fnet..." style="flex:1" autocomplete="off" />
<button class="btn" type="button" @click="fedConnect()" :disabled="federationLoading || !fedConnectTicket.trim()">Connect</button>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title">
@@ -2832,10 +2933,17 @@ function adminV2() {
agent_llm_auth: '',
agent_confidence_threshold: '',
agent_context_limit: '',
agent_concurrency: ''
agent_concurrency: '',
federation_enabled: false,
federation_network_id: '',
federation_save_on_listen: false
},
settingsProbe: { status: 'idle', ok: false },
settingsProbeLoading: false,
federationStatus: {},
federationLoading: false,
federationTicket: '',
fedConnectTicket: '',
settingsSaving: false,
routeReady: false,
poller: null,
@@ -3115,6 +3223,7 @@ function adminV2() {
body: JSON.stringify(this.settingsDraft)
});
await this.loadSettings(false);
await this.loadFederation(false);
this.showToast('Settings saved');
} catch (error) {
this.showToast(error.message);
@@ -3128,11 +3237,87 @@ function adminV2() {
this.activeView = 'settings';
this.setRoute('#settings');
await this.loadSettings();
await this.loadFederation(false);
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false);
}
},
async loadFederation(showErrors = true) {
this.federationLoading = true;
try {
this.federationStatus = await this.request(`${this.apiBase}/federation`);
} catch (error) {
if (showErrors) this.showToast(error.message);
} finally {
this.federationLoading = false;
this.icons();
}
},
async fedSyncNow() {
this.federationLoading = true;
try {
this.federationStatus = await this.request(`${this.apiBase}/federation/sync`, { method: 'POST', body: '{}' });
this.showToast('Library published to the federation');
} catch (error) {
this.showToast(error.message);
} finally {
this.federationLoading = false;
this.icons();
}
},
async fedShowTicket() {
this.federationLoading = true;
try {
const data = await this.request(`${this.apiBase}/federation/ticket`);
this.federationTicket = data.ticket || '';
} catch (error) {
this.showToast(error.message);
} finally {
this.federationLoading = false;
this.icons();
}
},
async fedConnect() {
this.federationLoading = true;
try {
const data = await this.request(`${this.apiBase}/federation/connect`, {
method: 'POST',
body: JSON.stringify({ ticket: this.fedConnectTicket.trim() })
});
this.fedConnectTicket = '';
this.showToast(`Connected to ${(data.connected || '').slice(0, 12)}…`);
await this.loadFederation(false);
} catch (error) {
this.showToast(error.message);
} finally {
this.federationLoading = false;
this.icons();
}
},
fedShort(id) {
return id ? `${id.slice(0, 12)}…` : '-';
},
fedTransport() {
return (this.federationStatus.node && this.federationStatus.node.transport) || {};
},
fedPathBadge(path) {
if (path === 'direct') return 'ok';
if (path === 'relay') return 'pending';
if (path === 'custom') return 'running';
return 'disabled';
},
fedRtt(ms) {
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
},
async loadSettingsProbe(showErrors = true) {
this.settingsProbeLoading = true;
try {
+138
View File
@@ -636,6 +636,144 @@
</div>
</template>
<!-- User Settings Modal -->
<template x-if="$store.user.settingsOpen">
<div class="modal-overlay" @click.self="$store.user.closeSettings()">
<div class="modal-box user-settings-modal">
<div class="user-settings-head">
<div>
<h3>User settings</h3>
<p>Personal services, listening history and trusted devices.</p>
</div>
<button class="mobile-list-action" @click="$store.user.closeSettings()" title="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<section class="user-settings-section">
<div class="user-settings-section-head">
<div>
<h4>Listening history</h4>
<p>Review plays recorded by this web player.</p>
</div>
<button class="settings-secondary-btn" @click="$store.user.openHistoryFromSettings()">Open history</button>
</div>
</section>
<section class="user-settings-section">
<div class="user-settings-section-head">
<div>
<h4>Last.fm</h4>
<p x-text="$store.user.lastfmStatusLabel()"></p>
</div>
<button class="settings-secondary-btn"
:class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()"
x-text="$store.user.lastfm?.connected && !$store.user.lastfm?.reauth_required ? 'Disconnect' : 'Connect'"></button>
</div>
</section>
<section class="user-settings-section user-settings-devices">
<div class="user-settings-section-head">
<div>
<h4>Connected devices</h4>
<p x-text="$store.devices.fedSummary()"></p>
</div>
<button class="settings-secondary-btn"
:disabled="$store.devices.fedBusy"
@click="$store.devices.syncFedDevices()">Sync now</button>
</div>
<template x-if="$store.devices.fedError">
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
</template>
<div class="settings-device-group">
<div class="settings-device-label">Web player sessions</div>
<template x-for="device in $store.devices.webDevices()" :key="'settings-web-' + device.id">
<div class="settings-device-row">
<span class="device-row-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8M12 16v4"/>
</svg>
</span>
<span class="fed-device-main">
<span class="fed-device-name" x-text="device.name"></span>
<span class="fed-device-meta"
x-text="device.is_current ? 'This browser session' : (device.is_active ? 'Active web session' : 'Web session')"></span>
</span>
</div>
</template>
</div>
<div class="settings-device-group">
<div class="settings-device-label">Trusted federation devices</div>
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
<div class="fed-pairing-card">
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
<div class="fed-pairing-meta"
x-text="request.requester_group_id ? 'Already belongs to another sync group' : (request.client_version || 'Waiting for approval')"></div>
<div class="fed-device-actions">
<button class="fed-action-btn primary"
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
x-text="request.requester_group_id ? 'Use existing group' : 'Approve'"></button>
<button class="fed-action-btn"
@click="$store.devices.answerFedPairing(request, false, false)">Reject</button>
</div>
</div>
</template>
<template x-for="device in $store.devices.fedDevices()" :key="'settings-fed-' + device.device_id">
<div class="settings-device-row federation">
<span class="device-row-icon federation-device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>
</span>
<span class="fed-device-main">
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
<span class="fed-device-meta"
x-text="device.is_self ? 'This web player' : (device.client_version || 'Trusted device')"></span>
</span>
<button class="fed-revoke-btn"
x-show="!device.is_self"
:disabled="$store.devices.fedBusy"
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
</div>
</template>
</div>
<div class="settings-pairing-actions">
<button class="settings-primary-btn"
:disabled="$store.devices.fedBusy"
@click="$store.devices.generateFedInvite()">Invite a device</button>
<template x-if="$store.devices.fedInvite">
<input class="fed-device-input"
readonly
:value="$store.devices.fedInvite"
@focus="$event.target.select()">
</template>
<div class="fed-connect-row">
<input class="fed-device-input"
type="text"
placeholder="Paste frid:// invite"
x-model="$store.devices.fedInviteInput"
@keydown.enter.prevent="$store.devices.connectFedInvite()">
<button class="settings-secondary-btn"
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
@click="$store.devices.connectFedInvite()">Connect</button>
</div>
</div>
</section>
</div>
</div>
</template>
<!-- Play History Modal -->
<template x-if="$store.history.modal">
<div class="modal-overlay" @click.self="$store.history.close()">
File diff suppressed because it is too large Load Diff
+516 -79
View File
@@ -16,13 +16,21 @@
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
<button class="user-logout-btn" @click="$store.user.logout()" title="{{ t.player_log_out }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
<div class="user-widget-actions">
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.7 1.7 0 00.34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0015 19.4a1.7 1.7 0 00-1 .6 1.7 1.7 0 00-.4 1.1V21h-4v-.1A1.7 1.7 0 009 19.4a1.7 1.7 0 00-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 004.6 15a1.7 1.7 0 00-.6-1 1.7 1.7 0 00-1.1-.4H3v-4h.1A1.7 1.7 0 004.6 9a1.7 1.7 0 00-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 009 4.6a1.7 1.7 0 001-.6 1.7 1.7 0 00.4-1.1V3h4v.1A1.7 1.7 0 0015 4.6a1.7 1.7 0 001.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0019.4 9a1.7 1.7 0 00.6 1 1.7 1.7 0 001.1.4h.1v4h-.1a1.7 1.7 0 00-1.7.6z"/>
</svg>
</button>
<button class="user-logout-btn" @click="$store.user.logout()" title="{{ t.player_log_out }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
<div class="user-stats">
<button class="user-stat" @click="$store.history.open()">
@@ -38,19 +46,6 @@
<span class="user-stat-label">{{ t.player_listened }}</span>
</div>
</div>
<button class="lastfm-profile-action"
:class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()"
:title="$store.user.lastfmLabel()"
:aria-label="$store.user.lastfmLabel()">
<span class="lastfm-dot"></span>
<span class="lastfm-profile-text">
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
<span class="lastfm-profile-separator">·</span>
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
</span>
</button>
</div>
<div class="sidebar-header">
<h2>{{ t.player_library }}</h2>
@@ -363,23 +358,21 @@
<span class="user-stat-label">{{ t.player_listened }}</span>
</div>
</div>
<button class="lastfm-profile-action"
:class="$store.user.lastfmClass()"
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
@click="$store.user.handleLastfm()"
:title="$store.user.lastfmLabel()"
:aria-label="$store.user.lastfmLabel()">
<span class="lastfm-dot"></span>
<span class="lastfm-profile-text">
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
<span class="lastfm-profile-separator">·</span>
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
</span>
</button>
<button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()">
{{ t.player_log_out }}
</button>
<div class="mobile-account-actions">
<button class="user-logout-btn"
@click="$store.user.menuOpen = false; $store.user.openSettings()"
title="User settings"
aria-label="User settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.7 1.7 0 00.34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0015 19.4a1.7 1.7 0 00-1 .6 1.7 1.7 0 00-.4 1.1V21h-4v-.1A1.7 1.7 0 009 19.4a1.7 1.7 0 00-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 004.6 15a1.7 1.7 0 00-.6-1 1.7 1.7 0 00-1.1-.4H3v-4h.1A1.7 1.7 0 004.6 9a1.7 1.7 0 00-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 009 4.6a1.7 1.7 0 001-.6 1.7 1.7 0 00.4-1.1V3h4v.1A1.7 1.7 0 0015 4.6a1.7 1.7 0 001.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0019.4 9a1.7 1.7 0 00.6 1 1.7 1.7 0 001.1.4h.1v4h-.1a1.7 1.7 0 00-1.7.6z"/>
</svg>
</button>
<button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()">
{{ t.player_log_out }}
</button>
</div>
</div>
</div>
@@ -505,6 +498,139 @@
</template>
</div>
</template>
<div class="search-section federation-search-section">
<h2 class="search-section-title">
Federation
<span class="federation-live-badge"
x-show="$store.library.federationSearch.loading"
x-cloak>LIVE</span>
</h2>
<template x-if="$store.library.federationSearch.error">
<div class="federation-search-status error"
x-text="$store.library.federationSearch.error"></div>
</template>
<template x-if="$store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
<div class="federation-search-status">Searching peers…</div>
</template>
<div class="search-artists-row"
x-show="$store.library.federationSearch.artists.length > 0"
x-cloak>
<template x-for="artist in $store.library.federationSearch.artists"
:key="artist.key.normalized_name">
<div class="search-artist-card federation-entity-card"
@click="$store.library.openFederatedArtist(artist)">
<div class="search-artist-img">
<img x-show="$store.library.federationArtistImage(artist)"
:src="$store.library.federationArtistImage(artist)"
:alt="artist.name"
loading="lazy"
@error="$event.currentTarget.style.display = 'none'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
</svg>
</div>
<div class="search-artist-name" x-text="artist.name"></div>
<div class="federation-source-count"
x-text="`${artist.peers.length} peer${artist.peers.length === 1 ? '' : 's'}`"></div>
</div>
</template>
</div>
<div class="search-releases-row"
x-show="$store.library.federationSearch.releases.length > 0"
x-cloak>
<template x-for="release in $store.library.federationSearch.releases"
:key="JSON.stringify(release.key)">
<div class="search-release-card federation-entity-card">
<div class="search-release-cover">
<img x-show="release.cover_url"
:src="release.cover_url"
:alt="release.title"
loading="lazy">
<svg x-show="!release.cover_url"
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>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle"
x-text="[release.year, ...(release.artists || [])].filter(Boolean).join(' · ')"></div>
</div>
</template>
</div>
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
:key="track.key.content_id">
<div class="track-row federation-track-row"
@dblclick="$store.library.playFederatedTrack(track)">
<span class="track-num federation-track-status">
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.isTrackLocal(track)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>
</template>
<template x-if="$store.library.federationDownload(track.key.content_id)">
<span class="federation-download-progress"
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle class="progress-track" cx="10" cy="10" r="8"/>
<circle class="progress-value" cx="10" cy="10" r="8"
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.key.content_id).percent / 100)}`"/>
</svg>
<span class="progress-percent"
x-show="$store.library.federationDownload(track.key.content_id).total"
x-text="Math.round($store.library.federationDownload(track.key.content_id).percent)"></span>
<span class="federation-download-tooltip"
x-text="$store.library.federationDownloadTooltip(track.key.content_id)"></span>
</span>
</template>
</span>
<div class="track-info">
<div class="track-title" x-text="track.metadata.title"></div>
<div class="track-artists-inline"
x-text="$store.library.federationArtistLine(track)"></div>
</div>
<span class="federation-source-count"
x-text="`${track.availability.federation.length} peer${track.availability.federation.length === 1 ? '' : 's'}`"></span>
<span class="track-actions">
<button class="track-action-btn info-btn"
@click.stop="$store.library.openFederationTrackInfo(track)"
title="{{ t.player_track_info }}">
<span class="info-letter">i</span>
</button>
<button class="like-btn"
:class="{ liked: $store.library.federationIsLiked(track) }"
@click.stop="$store.library.toggleFederationLike(track)"
title="{{ t.player_like }}">
<svg viewBox="0 0 24 24"
:fill="$store.library.federationIsLiked(track) ? '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.library.enqueueFederatedTrack(track, true)"
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.library.enqueueFederatedTrack(track, false)"
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 playlist-add-btn"
@click.stop="$store.playlists.showFederationPicker(track)"
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>
</span>
<span class="track-duration"
x-text="formatTime(track.metadata.duration_seconds)"></span>
</div>
</template>
</div>
</div>
</template>
</div>
@@ -580,7 +706,9 @@
<div class="artist-header">
<div class="artist-img">
<template x-if="$store.library.currentArtist.image_url">
<img :src="$store.library.currentArtist.image_url" :alt="$store.library.currentArtist.name">
<img :src="$store.library.currentArtist.image_url"
:alt="$store.library.currentArtist.name"
@error="$store.library.currentArtist.image_url = null">
</template>
<template x-if="!$store.library.currentArtist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
@@ -589,15 +717,18 @@
<div>
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
<div class="artist-stats">
<span x-text="$store.library.currentArtist.releases.length + ' {{ t.player_releases_count }}'"></span>
<span x-text="($store.library.currentArtist.releases.length + $store.library.artistFederation.releases.length) + ' {{ t.player_releases_count }}'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_track_count + ' {{ t.player_tracks_count }}'"></span>
<span x-text="($store.library.currentArtist.total_track_count + $store.library.artistFederation.tracks.length) + ' {{ t.player_tracks_count }}'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_play_count + ' {{ t.player_plays_count }}'"></span>
<span class="federation-live-badge"
x-show="$store.library.artistFederation.loading"
x-cloak>FEDERATION LIVE</span>
</div>
<div class="release-actions artist-actions">
<button class="release-action-btn primary artist-listen-action"
:disabled="!($store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length)"
:disabled="!($store.library.currentArtist.top_tracks?.length || $store.library.artistFederation.tracks.length)"
@click="$store.library.playArtistTopTracks()"
title="{{ t.player_listen_artist }}">
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">
@@ -606,6 +737,7 @@
<span>{{ t.player_listen }}</span>
</button>
<button class="release-action-btn secondary artist-follow-action"
x-show="$store.library.currentArtist.id"
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
@click="$store.follows.toggle($store.library.currentArtist.id)"
:title="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
@@ -705,7 +837,8 @@
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
<img :src="release.cover_url" :alt="release.title"
loading="lazy" @error="release.cover_url = null">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><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></span>
@@ -730,6 +863,120 @@
</div>
</section>
</template>
<template x-if="$store.library.artistFederation.releases.length > 0">
<section class="artist-release-group federation-artist-section">
<h2 class="artist-release-group-title">Federation</h2>
<div class="card-grid">
<template x-for="release in $store.library.artistFederation.releases"
:key="release.key">
<div class="card federation-release-card"
@click="$store.library.openFederatedRelease(release)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title"
loading="lazy" @error="release.cover_url = null">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><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></span>
</template>
<button class="card-play-btn"
:disabled="!release.tracks.length"
@click.stop="$store.library.playFederatedRelease(release)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.tracks.length + ' {{ t.player_tracks_count }}'"></span>
<span class="federation-source-count"
x-text="release.owners.length + ' peer' + (release.owners.length === 1 ? '' : 's')"></span>
</div>
</div>
</template>
</div>
</section>
</template>
<template x-if="$store.library.artistFederation.tracks.length > 0">
<section class="artist-release-group federation-artist-section">
<h2 class="artist-release-group-title">Federation tracks</h2>
<template x-for="(track, idx) in $store.library.artistFederation.tracks"
:key="track.key.content_id">
<div class="track-row federation-track-row"
@dblclick="$store.library.playFederatedTrack(track)">
<span class="track-num federation-track-status">
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.isTrackLocal(track)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
</svg>
</template>
<template x-if="$store.library.federationDownload(track.key.content_id)">
<span class="federation-download-progress"
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
<svg viewBox="0 0 20 20">
<circle class="progress-track" cx="10" cy="10" r="8"/>
<circle class="progress-value" cx="10" cy="10" r="8"
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.key.content_id).percent / 100)}`"/>
</svg>
<span class="progress-percent"
x-show="$store.library.federationDownload(track.key.content_id).total"
x-text="Math.round($store.library.federationDownload(track.key.content_id).percent)"></span>
<span class="federation-download-tooltip"
x-text="$store.library.federationDownloadTooltip(track.key.content_id)"></span>
</span>
</template>
</span>
<div class="track-info">
<div class="track-title" x-text="track.metadata.title"></div>
<div class="track-artists-inline"
x-text="[track.metadata.release?.title, $store.library.federationArtistLine(track)].filter(Boolean).join(' · ')"></div>
</div>
<span></span>
<span class="track-actions">
<button class="track-action-btn info-btn"
@click.stop="$store.library.openFederationTrackInfo(track)"
title="{{ t.player_track_info }}">
<span class="info-letter">i</span>
</button>
<button class="like-btn"
:class="{ liked: $store.library.federationIsLiked(track) }"
@click.stop="$store.library.toggleFederationLike(track)"
title="{{ t.player_like }}">
<svg viewBox="0 0 24 24"
:fill="$store.library.federationIsLiked(track) ? '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.library.enqueueFederatedTrack(track, true)"
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.library.enqueueFederatedTrack(track, false)"
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 playlist-add-btn"
@click.stop="$store.playlists.showFederationPicker(track)"
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>
</span>
<span class="track-duration"
x-text="formatTime(track.metadata.duration_seconds)"></span>
</div>
</template>
</section>
</template>
<template x-if="$store.library.artistFederation.error">
<div class="federation-search-status error"
x-text="$store.library.artistFederation.error"></div>
</template>
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">{{ t.player_appears_on }}</h2>
@@ -817,7 +1064,8 @@
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
<span>/</span>
<template x-if="$store.library.currentRelease.artists.length > 0">
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
<a @click="$store.library.currentRelease.artists[0].id ? $store.library.openArtist($store.library.currentRelease.artists[0].id) : $store.library.openFederatedArtist($store.library.currentRelease.artists[0].name)"
x-text="$store.library.currentRelease.artists[0].name"></a>
</template>
<span>/</span>
<span x-text="$store.library.currentRelease.title"></span>
@@ -832,21 +1080,28 @@
</template>
</div>
<div class="release-meta">
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
<div class="release-type">
<span x-text="$store.library.currentRelease.release_type"></span>
<span class="federation-live-badge"
x-show="$store.library.releaseFederationLoading"
x-cloak>FEDERATION LIVE</span>
</div>
<div class="release-title-row">
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
<button class="like-btn like-btn-lg release-title-like"
:class="{ liked: $store.likes.isReleaseLiked($store.library.currentRelease) }"
@click.stop="$store.likes.toggleRelease($store.library.currentRelease.id)"
:class="{ liked: $store.library.isMixedReleaseLiked($store.library.currentRelease) }"
@click.stop="$store.library.toggleMixedReleaseLike($store.library.currentRelease)"
title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.isReleaseLiked($store.library.currentRelease) ? '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>
<svg viewBox="0 0 24 24" :fill="$store.library.isMixedReleaseLiked($store.library.currentRelease) ? '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>
</div>
<div class="release-artists">
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
<a class="artist-link"
@click="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.name)"
x-text="artist.name"></a>
</span>
</template>
</div>
@@ -861,6 +1116,7 @@
{{ t.player_info }}
</button>
<button class="release-action-btn secondary release-share-btn"
x-show="!$store.library.currentRelease.federated"
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
title="{{ t.player_share }}"
aria-label="{{ t.player_share }}">
@@ -897,15 +1153,46 @@
<div class="track-row"
:data-shared-track-id="track.id"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
@dblclick="$store.queue.playRelease([track], 0)">
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
@dblclick="$store.queue.playRelease($store.library.currentRelease.tracks, idx)">
<span class="track-num federation-track-status">
<template x-if="!track.federation_pending">
<span x-text="track.track_number || (idx + 1)"></span>
</template>
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>
</template>
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && $store.library.isTrackLocal(track._federationTrack)">
<span x-text="track.track_number || (idx + 1)"></span>
</template>
<template x-if="track.federation_pending && $store.library.federationDownload(track.content_id)">
<span class="federation-download-progress"
:class="{ indeterminate: !$store.library.federationDownload(track.content_id).total }">
<svg viewBox="0 0 20 20">
<circle class="progress-track" cx="10" cy="10" r="8"/>
<circle class="progress-value" cx="10" cy="10" r="8"
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.content_id).percent / 100)}`"/>
</svg>
<span class="progress-percent"
x-show="$store.library.federationDownload(track.content_id).total"
x-text="Math.round($store.library.federationDownload(track.content_id).percent)"></span>
<span class="federation-download-tooltip"
x-text="$store.library.federationDownloadTooltip(track.content_id)"></span>
</span>
</template>
</span>
<div class="track-info">
<div class="track-title" x-text="track.title"></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>
<a class="artist-link"
@click.stop="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.label.replace(/^ft\\.\\s*/, ''))"
x-text="artist.label"></a>
</span>
</template>
</div>
@@ -915,14 +1202,19 @@
<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)"
@click.stop="track.federation_pending ? $store.library.openFederationTrackInfo(track._federationTrack) : $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 class="like-btn"
:class="{ liked: track.federation_pending ? $store.library.federationIsLiked(track._federationTrack) : $store.likes.has(track.id) }"
@click.stop="track.federation_pending ? $store.library.toggleFederationLike(track._federationTrack) : $store.likes.toggle(track.id)"
title="{{ t.player_like }}">
<svg viewBox="0 0 24 24"
:fill="track.federation_pending ? ($store.library.federationIsLiked(track._federationTrack) ? 'currentColor' : 'none') : ($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>
@@ -930,10 +1222,14 @@
<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 }}">
<button class="track-action-btn track-share-btn"
x-show="!track.federation_pending"
@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 }}">
<button class="track-action-btn playlist-add-btn"
@click.stop="track.federation_pending ? $store.playlists.showFederationPicker(track._federationTrack) : $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>
@@ -951,7 +1247,23 @@
<span>/</span>
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
</div>
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
<div class="playlist-detail-heading">
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
<button class="release-action-btn secondary playlist-edit-toggle"
x-show="$store.playlists.canEditCurrent()"
x-cloak
@click="$store.playlists.toggleEditCurrent()"
:title="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'">
<svg x-show="!$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<svg x-show="$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span x-text="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'"></span>
</button>
</div>
<div class="playlist-detail-meta"
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
<span x-show="$store.library.currentPlaylist.owner_name"
@@ -963,27 +1275,89 @@
<template x-if="$store.library.currentPlaylist.description">
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
</template>
<div class="track-list-header">
<div class="track-list-header playlist-track-list-header"
:class="{ editing: $store.playlists.isEditingCurrent() }">
<span class="playlist-edit-cell"
x-show="$store.playlists.isEditingCurrent()"
x-cloak></span>
<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.currentPlaylist.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></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>
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.playlist_track_id || (track.id + '-' + idx)">
<div class="track-row playlist-track-row"
:data-playlist-index="idx"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, editing: $store.playlists.isEditingCurrent(), dragging: $store.playlists._dragIdx === idx }"
:draggable="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
@dblclick="if (!$store.playlists.isEditingCurrent()) $store.queue.playRelease($store.library.currentPlaylist.tracks, idx)"
@dragstart="if (!$store.playlists.startDrag($event, idx)) $event.preventDefault()"
@dragend="$store.playlists.endDrag()"
@dragover.prevent="$store.playlists.dragOver($event, idx)"
@dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); $store.playlists.dropOn(idx)">
<button class="playlist-track-remove"
x-show="$store.playlists.isEditingCurrent()"
x-cloak
@click.stop="$store.playlists.removeCurrentTrack(track, idx)"
title="{{ t.player_remove }}"
aria-label="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<span class="track-num federation-track-status">
<template x-if="!track.federation_pending || $store.library.isTrackLocal(track._federationTrack)">
<span x-text="idx + 1"></span>
</template>
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>
</template>
<template x-if="track.federation_pending && $store.library.federationDownload(track.content_id)">
<span class="federation-download-progress"
:class="{ indeterminate: !$store.library.federationDownload(track.content_id).total }">
<svg viewBox="0 0 20 20">
<circle class="progress-track" cx="10" cy="10" r="8"/>
<circle class="progress-value" cx="10" cy="10" r="8"
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.content_id).percent / 100)}`"/>
</svg>
<span class="progress-percent"
x-show="$store.library.federationDownload(track.content_id).total"
x-text="Math.round($store.library.federationDownload(track.content_id).percent)"></span>
<span class="federation-download-tooltip"
x-text="$store.library.federationDownloadTooltip(track.content_id)"></span>
</span>
</template>
</span>
<div class="track-info playlist-track-info">
<button class="playlist-drag-handle"
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
x-cloak
@mousedown.stop
@click.stop
@pointerdown.stop="$store.playlists.startPointerReorder($event, idx)"
title="{{ t.player_edit }}"
aria-label="{{ t.player_edit }}">
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</button>
<div class="playlist-track-copy">
<div class="track-title" x-text="track.title"></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="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.label.replace(/^ft\\.\\s*/, ''))"
x-text="artist.label"></a>
</span>
</template>
</div>
</div>
</div>
<span></span>
@@ -991,14 +1365,19 @@
<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)"
@click.stop="track.federation_pending ? $store.library.openFederationTrackInfo(track._federationTrack) : $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 class="like-btn"
:class="{ liked: track.federation_pending ? $store.library.federationIsLiked(track._federationTrack) : $store.likes.has(track.id) }"
@click.stop="track.federation_pending ? $store.library.toggleFederationLike(track._federationTrack) : $store.likes.toggle(track.id)"
title="{{ t.player_like }}">
<svg viewBox="0 0 24 24"
:fill="track.federation_pending ? ($store.library.federationIsLiked(track._federationTrack) ? 'currentColor' : 'none') : ($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>
@@ -1009,7 +1388,9 @@
<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 }}">
<button class="track-action-btn playlist-add-btn"
@click.stop="track.federation_pending ? $store.playlists.showFederationPicker(track._federationTrack) : $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>
@@ -1068,6 +1449,31 @@
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
<span class="queue-federation-status federation-track-status"
x-show="item.track.federation_pending"
x-cloak>
<template x-if="!$store.library.federationDownload(item.track.content_id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
</svg>
</template>
<template x-if="$store.library.federationDownload(item.track.content_id)">
<span class="federation-download-progress"
:class="{ indeterminate: !$store.library.federationDownload(item.track.content_id).total }">
<svg viewBox="0 0 20 20">
<circle class="progress-track" cx="10" cy="10" r="8"/>
<circle class="progress-value" cx="10" cy="10" r="8"
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(item.track.content_id).percent / 100)}`"/>
</svg>
<span class="progress-percent"
x-show="$store.library.federationDownload(item.track.content_id).total"
x-text="Math.round($store.library.federationDownload(item.track.content_id).percent)"></span>
<span class="federation-download-tooltip"
x-text="$store.library.federationDownloadTooltip(item.track.content_id)"></span>
</span>
</template>
</span>
</div>
<div class="queue-track-info">
<div class="queue-track-title" x-text="item.track.title"></div>
@@ -1075,7 +1481,9 @@
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.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>
<a class="artist-link"
@click.stop="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.label.replace(/^ft\\.\\s*/, ''))"
x-text="artist.label"></a>
</span>
</template>
</div>
@@ -1084,7 +1492,7 @@
<button class="queue-track-remove info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
:style="$store.library.popularityStyle(item.track)"
@click.stop="$store.library.openTrackInfo(item.track)"
@click.stop="item.track.federation_pending ? $store.library.openFederationTrackInfo(item.track._federationTrack) : $store.library.openTrackInfo(item.track)"
:title="$store.library.trackInfoTitle(item.track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
@@ -1255,7 +1663,8 @@
</span>
</button>
<div class="device-popover" x-show="$store.devices.open" x-transition x-cloak>
<template x-for="device in $store.devices.devices" :key="device.id">
<div class="device-section-label">Web players</div>
<template x-for="device in $store.devices.webDevices()" :key="device.id">
<button class="device-row"
:class="{ active: device.is_active, 'current-device': device.is_current }"
@click="$store.devices.select(device.id)">
@@ -1285,6 +1694,34 @@
</span>
</button>
</template>
<template x-if="$store.devices.playbackFedDevices().length > 0">
<div class="device-group-divider">
<span>Trusted federation devices</span>
</div>
</template>
<template x-for="device in $store.devices.playbackFedDevices()" :key="device.id">
<button class="device-row federation-device-row"
:class="{ active: device.is_active }"
@click="$store.devices.select(device.id)">
<span class="device-row-icon federation-device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>
</span>
<span class="device-row-main">
<span class="device-row-name" x-text="device.name"></span>
<span class="device-row-current"
x-text="device.is_active ? 'Active federation device' : 'Available for playback transfer'"></span>
</span>
<span class="device-row-check" x-show="device.is_active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
</button>
</template>
<template x-if="$store.devices.jams.length > 0">
<div class="device-section-label jam-section-label">Jams</div>
</template>
+548 -8
View File
@@ -67,11 +67,17 @@ body {
.user-widget-main {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 32px;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.user-widget-actions {
display: flex;
align-items: center;
gap: 2px;
}
.user-avatar {
width: 36px;
height: 36px;
@@ -489,6 +495,25 @@ button.user-stat:hover {
font-size: 13px;
}
.playlist-detail-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.playlist-detail-heading .section-title {
min-width: 0;
margin-bottom: 0;
overflow-wrap: anywhere;
}
.playlist-edit-toggle {
flex: 0 0 auto;
margin-top: 4px;
}
.breadcrumb {
display: flex;
align-items: center;
@@ -735,6 +760,93 @@ button.user-stat:hover {
.track-row:hover { background: var(--bg-hover); }
.track-row.playing { color: var(--accent); }
.track-row.playing .track-num { color: var(--accent); }
.playlist-track-list-header.editing,
.playlist-track-row.editing {
grid-template-columns: 32px 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
}
.playlist-track-row.editing {
cursor: grab;
}
.playlist-track-row.editing:active {
cursor: grabbing;
}
.playlist-track-row.dragging {
opacity: 0.45;
}
.playlist-track-row.drag-over {
border-top: 2px solid var(--accent);
margin-top: -2px;
}
.playlist-track-remove {
width: 28px;
height: 28px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-subdued);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: color 0.15s, background 0.15s;
}
.playlist-track-remove:hover {
color: var(--text-primary);
background: var(--bg-active);
}
.playlist-track-remove svg {
width: 16px;
height: 16px;
}
.playlist-track-info {
display: flex;
align-items: center;
gap: 10px;
}
.playlist-track-copy {
min-width: 0;
overflow: hidden;
}
.playlist-drag-handle {
width: 24px;
height: 28px;
border: 0;
background: transparent;
color: var(--text-subdued);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
padding: 0;
touch-action: none;
user-select: none;
}
.playlist-drag-handle:active {
cursor: grabbing;
}
.playlist-drag-handle:hover {
color: var(--text-primary);
}
.playlist-drag-handle svg {
width: 14px;
height: 14px;
}
.track-row.shared-target {
background: rgba(29, 185, 84, 0.12);
box-shadow: inset 3px 0 0 var(--accent);
@@ -1251,20 +1363,50 @@ button.user-stat:hover {
}
.queue-track-cover {
position: relative;
width: 40px;
height: 40px;
border-radius: 4px;
background: var(--bg-elevated);
overflow: hidden;
overflow: visible;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; }
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
.queue-track-cover .queue-federation-status {
position: absolute;
right: 2px;
bottom: 2px;
z-index: 3;
width: 18px;
min-width: 18px;
max-width: 18px;
height: 18px;
min-height: 18px;
max-height: 18px;
box-sizing: border-box;
flex: none;
border-radius: 50%;
background: var(--bg-elevated);
box-shadow: 0 0 0 1px var(--bg-secondary);
}
.queue-track-cover .queue-federation-status > svg {
width: 13px;
height: 13px;
}
.queue-track-cover .queue-federation-status .federation-download-progress,
.queue-track-cover .queue-federation-status .federation-download-progress svg {
width: 18px !important;
min-width: 18px;
height: 18px !important;
min-height: 18px;
}
.queue-track-info { overflow: hidden; flex: 1; }
.queue-track-title {
font-size: 13px;
@@ -1750,9 +1892,9 @@ button.user-stat:hover {
position: absolute;
right: 0;
bottom: 38px;
width: 260px;
width: 320px;
max-width: calc(100vw - 24px);
max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px));
max-height: min(440px, calc(100dvh - var(--player-bar-space) - 24px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border-color);
@@ -1849,6 +1991,169 @@ button.user-stat:hover {
text-transform: uppercase;
}
.device-group-divider {
display: flex;
align-items: center;
gap: 8px;
margin: 7px 8px 3px;
color: #b8d6ff;
font-size: 10px;
font-weight: 750;
text-transform: uppercase;
}
.device-group-divider::before,
.device-group-divider::after {
content: "";
height: 1px;
flex: 1;
background: var(--border-color);
}
.device-group-divider span {
white-space: nowrap;
}
.federation-device-row {
color: #c9dcff;
}
.federation-device-icon {
color: #78a9ff;
}
.fed-section-label {
margin-top: 4px;
color: #b8d6ff;
}
.fed-device-panel {
margin: 2px 2px 6px;
padding: 8px;
border: 1px solid rgba(82,145,255,0.18);
border-radius: 6px;
background: rgba(82,145,255,0.045);
display: grid;
gap: 7px;
}
.fed-device-status,
.fed-device-error,
.fed-pairing-note {
color: var(--text-subdued);
font-size: 11px;
line-height: 1.35;
}
.fed-device-error {
color: #ffb2b2;
}
.fed-device-row {
min-height: 32px;
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.fed-device-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #73d795;
}
.fed-device-dot.self {
background: #ffd166;
}
.fed-device-main {
min-width: 0;
display: grid;
gap: 1px;
}
.fed-device-name,
.fed-pairing-title {
color: var(--text-primary);
font-size: 12px;
font-weight: 750;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fed-device-meta,
.fed-pairing-meta {
color: var(--text-subdued);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fed-pairing-card {
padding: 7px;
border-radius: 5px;
background: rgba(255,255,255,0.04);
display: grid;
gap: 5px;
}
.fed-device-actions,
.fed-connect-row {
display: flex;
gap: 6px;
min-width: 0;
}
.fed-connect-row .fed-device-input {
flex: 1;
}
.fed-action-btn,
.fed-revoke-btn {
height: 28px;
border: 0;
border-radius: 4px;
background: rgba(255,255,255,0.08);
color: var(--text-secondary);
padding: 0 8px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.fed-action-btn:hover,
.fed-revoke-btn:hover {
background: rgba(255,255,255,0.13);
color: var(--text-primary);
}
.fed-action-btn.primary {
background: rgba(82,145,255,0.16);
color: #c9dcff;
}
.fed-action-btn:disabled {
opacity: 0.45;
cursor: default;
}
.fed-revoke-btn {
background: rgba(255,96,96,0.1);
color: #ffb2b2;
}
.fed-device-input {
width: 100%;
min-width: 0;
height: 30px;
border: 1px solid rgba(82,145,255,0.2);
border-radius: 4px;
background: rgba(0,0,0,0.18);
color: var(--text-primary);
padding: 0 8px;
font-size: 12px;
}
.jam-section-label,
.jam-row,
.start-jam-row,
@@ -2194,11 +2499,18 @@ button.user-stat:hover {
}
.mobile-account-logout {
width: 100%;
margin-top: 12px;
flex: 1;
margin: 0;
justify-content: center;
}
.mobile-account-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.torrent-import-btn {
display: flex;
align-items: center;
@@ -2390,6 +2702,105 @@ button.user-stat:hover {
/* Search results */
.search-section { margin-bottom: 24px; }
.federation-search-section {
border-top: 1px solid var(--border);
padding-top: 18px;
}
.federation-live-badge {
margin-left: 8px;
color: var(--accent);
font-size: 10px;
letter-spacing: .12em;
}
.federation-search-status {
color: var(--text-muted);
padding: 12px 0;
}
.federation-search-status.error { color: var(--danger, #e66); }
.federation-track-status {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.federation-track-status > svg {
width: 18px;
height: 18px;
color: var(--accent);
}
.federation-download-progress {
position: relative;
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
}
.federation-download-progress svg {
width: 22px !important;
height: 22px !important;
transform: rotate(-90deg);
overflow: hidden;
}
.federation-download-progress circle {
fill: none;
stroke-width: 2;
}
.federation-download-progress .progress-track {
stroke: color-mix(in srgb, var(--text-muted) 28%, transparent);
}
.federation-download-progress .progress-value {
stroke: var(--accent);
stroke-linecap: round;
stroke-dasharray: 50.27;
transition: stroke-dashoffset 160ms linear;
}
.federation-download-progress.indeterminate .progress-value {
stroke-dasharray: 12.57 37.70;
transform-box: fill-box;
transform-origin: center;
animation: federation-progress-spin .8s linear infinite;
}
.federation-download-progress .progress-percent {
position: absolute;
color: var(--text);
font-size: 7px;
font-weight: 700;
line-height: 1;
}
.federation-download-tooltip {
position: absolute;
z-index: 1000;
left: calc(100% + 10px);
top: 50%;
width: max-content;
max-width: 290px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-elevated);
box-shadow: 0 6px 18px rgba(0, 0, 0, .28);
color: var(--text);
font-size: 11px;
font-weight: 500;
line-height: 1.35;
opacity: 0;
pointer-events: none;
transform: translate(3px, -50%);
transition: opacity 120ms ease, transform 120ms ease;
}
.federation-download-progress:hover .federation-download-tooltip {
opacity: 1;
transform: translate(0, -50%);
}
@keyframes federation-progress-spin {
to { transform: rotate(360deg); }
}
.federation-source-count {
color: var(--text-muted);
font-size: 12px;
}
.search-section-title {
font-size: 18px;
font-weight: 700;
@@ -2433,7 +2844,7 @@ button.user-stat:hover {
position: relative;
}
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
.search-artist-img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.search-artist-img svg { width: 32px; height: 32px; color: var(--text-subdued); }
.search-artist-name {
@@ -3129,6 +3540,105 @@ button.user-stat:hover {
max-width: 980px;
}
.user-settings-modal {
width: min(760px, calc(100vw - 32px));
max-width: 760px;
max-height: min(86dvh, 820px);
overflow-y: auto;
}
.user-settings-head,
.user-settings-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.user-settings-head {
margin-bottom: 16px;
}
.user-settings-head h3,
.user-settings-section h4 {
margin: 0;
}
.user-settings-head p,
.user-settings-section p {
margin: 4px 0 0;
color: var(--text-subdued);
font-size: 12px;
line-height: 1.45;
}
.user-settings-section {
padding: 16px 0;
border-top: 1px solid var(--border-color);
}
.user-settings-devices {
display: grid;
gap: 12px;
}
.settings-device-group {
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.settings-device-label {
padding: 8px 11px;
border-bottom: 1px solid var(--border-color);
color: var(--text-subdued);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.settings-device-row {
min-height: 48px;
padding: 8px 11px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.settings-device-row:last-child {
border-bottom: 0;
}
.settings-device-row.federation {
background: rgba(82, 145, 255, 0.035);
}
.settings-primary-btn,
.settings-secondary-btn {
min-height: 34px;
padding: 7px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-hover);
color: var(--text-primary);
cursor: pointer;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.settings-primary-btn {
border-color: rgba(82, 145, 255, 0.4);
background: rgba(82, 145, 255, 0.18);
color: #d5e4ff;
}
.settings-primary-btn:hover,
.settings-secondary-btn:hover {
filter: brightness(1.12);
}
.settings-primary-btn:disabled,
.settings-secondary-btn:disabled {
cursor: default;
opacity: .55;
}
.settings-pairing-actions {
display: grid;
gap: 8px;
}
.settings-pairing-actions > .settings-primary-btn {
justify-self: start;
}
.history-head {
display: flex;
align-items: flex-start;
@@ -4233,6 +4743,16 @@ button.user-stat:hover {
justify-content: flex-end;
}
.playlist-track-list-header.editing,
.playlist-track-row.editing {
grid-template-columns: 30px 32px minmax(0, 1fr) auto 54px;
}
.playlist-track-list-header.editing span:nth-child(4),
.playlist-track-row.editing > span:nth-child(4) {
display: none;
}
.history-table-head,
.history-row.track-row {
grid-template-columns: 44px minmax(0, 1fr) auto;
@@ -5158,6 +5678,26 @@ button.user-stat:hover {
padding: 10px 6px;
}
.playlist-detail-heading {
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.playlist-edit-toggle {
padding: 8px 10px;
margin-top: 0;
}
.playlist-track-row.editing {
grid-template-columns: 30px minmax(0, 1fr) auto;
}
.playlist-track-row.editing .track-num,
.playlist-track-row.editing > span:nth-child(4) {
display: none;
}
.track-row > span:nth-child(3),
.track-duration {
display: none;