Compare commits

...
11 Commits
Author SHA1 Message Date
Ultradesu 134f89fc9d Bump libs
Build and Publish / Build and Publish Docker Image (push) Successful in 5m18s
2026-07-27 23:17:07 +01:00
Ultradesu 232c171aac Add synced listening history 2026-07-27 23:16:03 +01:00
Ultradesu 845df4e031 Fixed autoplay after fed download 2026-07-27 17:33:48 +01:00
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
20 changed files with 5936 additions and 544 deletions
Generated
+19 -26
View File
@@ -806,15 +806,6 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -1575,9 +1566,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.16.0"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
dependencies = [
"serde",
]
@@ -1673,11 +1664,10 @@ dependencies = [
[[package]]
name = "event-listener"
version = "5.4.1"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@@ -1718,7 +1708,7 @@ dependencies = [
[[package]]
name = "federation-net"
version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [
"blake3",
"data-encoding",
@@ -1845,12 +1835,14 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.7.1"
version = "0.9.4"
dependencies = [
"anyhow",
"async-stream",
"async-trait",
"base64 0.22.1",
"blake3",
"bytes",
"chrono",
"cot",
"croner",
@@ -3621,8 +3613,8 @@ dependencies = [
[[package]]
name = "music-dht"
version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
version = "0.2.0"
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [
"async-trait",
"blake3",
@@ -3766,12 +3758,13 @@ dependencies = [
[[package]]
name = "netlink-proto"
version = "0.12.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6"
dependencies = [
"bytes",
"futures",
"futures-channel",
"futures-util",
"log",
"netlink-packet-core",
"netlink-sys",
@@ -5293,9 +5286,9 @@ dependencies = [
[[package]]
name = "schemars"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
@@ -5556,7 +5549,7 @@ dependencies = [
"indexmap 1.9.3",
"indexmap 2.14.0",
"schemars 0.9.0",
"schemars 1.2.1",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
@@ -6613,9 +6606,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.8.0"
version = "0.9.4"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
@@ -14,6 +14,8 @@ 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"
+11
View File
@@ -452,6 +452,8 @@ struct AdminSettingsValues {
federation_enabled: bool,
#[serde(default)]
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -478,6 +480,7 @@ struct AdminSettingsSources {
agent_concurrency: &'static str,
federation_enabled: &'static str,
federation_network_id: &'static str,
federation_save_on_listen: &'static str,
}
#[derive(Debug, Deserialize)]
@@ -506,6 +509,8 @@ pub(super) struct UpdateSettingsRequest {
federation_enabled: bool,
#[serde(default)]
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -993,6 +998,10 @@ pub async fn update_settings(
"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);
@@ -1143,6 +1152,7 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
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(),
@@ -1167,6 +1177,7 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
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(),
},
}
}
+8
View File
@@ -137,6 +137,7 @@ pub struct ConfigSources {
pub lastfm_shared_secret: ConfigSource,
pub federation_enabled: ConfigSource,
pub federation_network_id: ConfigSource,
pub federation_save_on_listen: ConfigSource,
}
impl Default for ConfigSources {
@@ -166,6 +167,7 @@ impl Default for ConfigSources {
lastfm_shared_secret: ConfigSource::Default,
federation_enabled: ConfigSource::Default,
federation_network_id: ConfigSource::Default,
federation_save_on_listen: ConfigSource::Default,
}
}
}
@@ -280,6 +282,9 @@ pub struct AppConfig {
/// 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 {
@@ -309,6 +314,7 @@ impl Default for AppConfig {
lastfm_shared_secret: String::new(),
federation_enabled: false,
federation_network_id: String::new(),
federation_save_on_listen: false,
}
}
}
@@ -339,6 +345,7 @@ impl_env_overrides!(
lastfm_shared_secret,
federation_enabled,
federation_network_id,
federation_save_on_listen,
);
impl AppConfig {
@@ -468,6 +475,7 @@ impl AppConfig {
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()
}
+583 -57
View File
@@ -11,6 +11,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use music_dht::device_sync::{ListenEndReason, ListenEvent, ListenTrackMetadata};
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
use serde::{Deserialize, Serialize};
use sqlx::Row;
@@ -18,15 +19,18 @@ use tokio::io::{AsyncRead, AsyncReadExt};
use crate::player::PlayerDeviceHub;
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
use super::{TransportStats, record_stream_transport};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u16 = 1;
const PROTOCOL_VERSION: u16 = 2;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
const MAX_LINE: usize = 8 * 1024 * 1024;
const MAX_OPS_PER_BATCH: i64 = 1000;
@@ -266,6 +270,9 @@ enum SyncOpPayload {
target_device_id: String,
command: PlaybackCommand,
},
ListenRecorded {
event: ListenEvent,
},
}
impl SyncOpPayload {
@@ -456,6 +463,7 @@ pub async fn connect_invite(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
user_id: i64,
user_name: &str,
invite_link: &str,
@@ -470,6 +478,7 @@ pub async fn connect_invite(
pool,
Arc::clone(&service),
Arc::clone(&hub),
Arc::clone(&transport_stats),
user_id,
user_name,
&invite,
@@ -596,6 +605,7 @@ pub async fn revoke_device(pool: &sqlx::PgPool, user_id: i64, device_id: &str) -
pub async fn status(pool: &sqlx::PgPool, user_id: i64, user_name: &str) -> Result<FedDeviceStatus> {
let identity = ensure_identity(pool, user_id, user_name).await?;
maybe_seed_local_user_state(pool, user_id).await?;
let active_devices: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM furumusic__fed_device
WHERE user_id = $1 AND trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL",
@@ -746,13 +756,18 @@ pub async fn record_track_like(
liked: bool,
) -> Result<()> {
if let Some(content_id) = track_content_id(pool, track_id).await? {
let fed = if liked {
synced_fed_track_for_track(pool, track_id, &content_id).await?
} else {
None
};
record_local_op(
pool,
user_id,
SyncOpPayload::TrackLikeSet {
content_id,
liked,
fed: None,
fed,
},
)
.await?;
@@ -760,6 +775,184 @@ pub async fn record_track_like(
Ok(())
}
pub async fn record_track_listen(
pool: &sqlx::PgPool,
user_id: i64,
track_id: i64,
listen_id: String,
started_at_ms: i64,
listened_ms: i64,
ended_reason: ListenEndReason,
) -> Result<()> {
let content_id = track_content_id(pool, track_id)
.await?
.context("track content id is not ready")?;
let track = synced_fed_track_for_track(pool, track_id, &content_id)
.await?
.context("track metadata is not available")?;
let event = ListenEvent {
listen_id,
content_id,
started_at_ms,
listened_ms,
track_duration_ms: track.duration_seconds.map(|seconds| seconds * 1_000),
ended_reason,
track: ListenTrackMetadata {
title: track.title,
artist_names: track.artist_names,
featured_artist_names: track.featured_artist_names,
release_title: track.release_title,
},
};
if event.should_record() {
record_local_op(pool, user_id, SyncOpPayload::ListenRecorded { event }).await?;
}
Ok(())
}
/// Records a like for a content-addressed track that need not be local yet.
/// The ordinary local likes table remains a projection for materialized
/// tracks; the durable user intent lives under `content_id`.
pub async fn record_content_like(
pool: &sqlx::PgPool,
user_id: i64,
content_id: &str,
liked: bool,
fed: Option<serde_json::Value>,
) -> Result<()> {
let content_id =
music_dht::normalize_content_id(content_id).context("invalid track content id")?;
let fed = fed
.map(serde_json::from_value::<SyncedFedTrack>)
.transpose()
.context("invalid federated track metadata")?;
record_local_op(
pool,
user_id,
SyncOpPayload::TrackLikeSet {
content_id,
liked,
fed: liked.then_some(fed).flatten(),
},
)
.await
}
/// Adds a content-addressed track to a user's playlist without requiring a
/// local numeric track row. Materialization later fills `local_track_id`
/// without replacing this playlist item.
pub async fn record_content_playlist_add(
pool: &sqlx::PgPool,
user_id: i64,
playlist_id: i64,
content_id: &str,
position: i64,
fed: Option<serde_json::Value>,
) -> Result<()> {
let content_id =
music_dht::normalize_content_id(content_id).context("invalid track content id")?;
let fed = fed
.map(serde_json::from_value::<SyncedFedTrack>)
.transpose()
.context("invalid federated track metadata")?;
let title = playlist_title(pool, playlist_id)
.await?
.context("playlist not found")?;
let sync_id = ensure_local_playlist_sync_id(pool, user_id, playlist_id, &title).await?;
record_local_op(
pool,
user_id,
SyncOpPayload::PlaylistTrackAdded {
playlist_id: sync_id,
content_id,
position,
fed,
},
)
.await
}
/// Reconciles content-addressed user state after a track becomes local.
/// History is intentionally not touched: only actual web playback writes it.
pub async fn materialize_content_state(
pool: &sqlx::PgPool,
content_id: &str,
track_id: i64,
) -> Result<()> {
let content_id =
music_dht::normalize_content_id(content_id).context("invalid track content id")?;
let likes = sqlx::query(
"SELECT user_id, liked, hlc_ms
FROM furumusic__fed_state_like WHERE content_id = $1",
)
.bind(&content_id)
.fetch_all(pool)
.await?;
for row in likes {
let user_id: i64 = row.get("user_id");
let liked: bool = row.get("liked");
if liked {
sqlx::query(
"INSERT INTO furumusic__user_liked_track
(user_id, track_id, created_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, track_id) DO NOTHING",
)
.bind(user_id)
.bind(track_id)
.bind(iso_from_ms(row.get("hlc_ms")))
.execute(pool)
.await?;
}
}
let playlist_items = sqlx::query(
"SELECT i.user_id, i.playlist_id, i.position
FROM furumusic__fed_state_playlist_item i
WHERE i.content_id = $1 AND i.present = true",
)
.bind(&content_id)
.fetch_all(pool)
.await?;
for row in playlist_items {
let user_id: i64 = row.get("user_id");
let sync_id: String = row.get("playlist_id");
let playlist_id = ensure_playlist_for_item(pool, user_id, &sync_id).await?;
sqlx::query(
"INSERT INTO furumusic__playlist_track
(playlist_id, track_id, position, added_at, added_by_user_id)
SELECT $1, $2, $3, $4, $5
WHERE NOT EXISTS (
SELECT 1 FROM furumusic__playlist_track
WHERE playlist_id = $1 AND track_id = $2
)",
)
.bind(playlist_id)
.bind(track_id)
.bind(row.get::<i64, _>("position") as i32)
.bind(now_iso())
.bind(user_id)
.execute(pool)
.await?;
}
sqlx::query(
"UPDATE furumusic__fed_state_like
SET local_track_id = $2 WHERE content_id = $1",
)
.bind(&content_id)
.bind(track_id)
.execute(pool)
.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(pool)
.await?;
Ok(())
}
pub async fn record_playlist_created(
pool: &sqlx::PgPool,
user_id: i64,
@@ -828,7 +1021,8 @@ pub async fn record_playlist_tracks_added(
let title = playlist_title(pool, playlist_id).await?.unwrap_or_default();
let sync_id = ensure_local_playlist_sync_id(pool, user_id, playlist_id, &title).await?;
let rows = playlist_track_content_positions(pool, playlist_id, track_ids).await?;
for (content_id, position) in rows {
for (track_id, content_id, position) in rows {
let fed = synced_fed_track_for_track(pool, track_id, &content_id).await?;
record_local_op(
pool,
user_id,
@@ -836,7 +1030,7 @@ pub async fn record_playlist_tracks_added(
playlist_id: sync_id.clone(),
content_id,
position,
fed: None,
fed,
},
)
.await?;
@@ -876,14 +1070,16 @@ pub async fn serve_peers(
pool: sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let pool = pool.clone();
let service = Arc::clone(&service);
let hub = Arc::clone(&hub);
let transport_stats = Arc::clone(&transport_stats);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_one(stream, &pool, service, hub).await {
if let Err(err) = serve_one(stream, &pool, service, hub, transport_stats).await {
tracing::warn!(peer = %peer, "web fed device sync stream failed: {err:#}");
}
});
@@ -894,11 +1090,19 @@ pub async fn sync_loop(
pool: sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
) {
let mut interval = tokio::time::interval(DEVICE_SYNC_INTERVAL);
loop {
interval.tick().await;
if let Err(err) = sync_once_all(&pool, Arc::clone(&service), Arc::clone(&hub)).await {
if let Err(err) = sync_once_all(
&pool,
Arc::clone(&service),
Arc::clone(&hub),
Arc::clone(&transport_stats),
)
.await
{
tracing::debug!("web fed device sync tick failed: {err:#}");
}
}
@@ -908,6 +1112,7 @@ pub async fn sync_once_all(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
) -> Result<()> {
let rows = sqlx::query(
"SELECT DISTINCT user_id FROM furumusic__fed_device
@@ -917,7 +1122,15 @@ pub async fn sync_once_all(
.await?;
for row in rows {
let user_id: i64 = row.get("user_id");
if let Err(err) = sync_once(pool, Arc::clone(&service), Arc::clone(&hub), user_id).await {
if let Err(err) = sync_once(
pool,
Arc::clone(&service),
Arc::clone(&hub),
Arc::clone(&transport_stats),
user_id,
)
.await
{
set_last_error(pool, user_id, Some(&format!("{err:#}"))).await?;
}
}
@@ -928,6 +1141,7 @@ pub async fn sync_once(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
user_id: i64,
) -> Result<()> {
let devices = active_remote_devices(pool, user_id).await?;
@@ -939,6 +1153,7 @@ pub async fn sync_once(
pool,
Arc::clone(&service),
Arc::clone(&hub),
Arc::clone(&transport_stats),
user_id,
&device,
)
@@ -960,6 +1175,7 @@ async fn try_connect_invite(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
user_id: i64,
user_name: &str,
invite: &InviteWire,
@@ -976,6 +1192,7 @@ async fn try_connect_invite(
let snapshot = snapshot(pool, user_id).await?;
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
record_stream_transport(&transport_stats, "device-sync", "outbound", "open", &stream);
write_msg(
&mut stream,
&WireMessage::PairRequest {
@@ -993,10 +1210,11 @@ async fn try_connect_invite(
)
.await?;
finish_send(&mut stream).await?;
match read_msg(&mut stream)
let response = read_msg(&mut stream)
.await
.context("pairing response was not received")?
{
.context("pairing response was not received")?;
record_stream_transport(&transport_stats, "device-sync", "outbound", "done", &stream);
match response {
WireMessage::PairResponse {
accepted: true,
group_id: Some(group_id),
@@ -1060,7 +1278,9 @@ async fn serve_one(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
) -> Result<()> {
record_stream_transport(&transport_stats, "device-sync", "inbound", "open", &stream);
match read_msg(&mut stream).await? {
WireMessage::PairRequest {
invite_id,
@@ -1079,6 +1299,7 @@ async fn serve_one(
pool,
service,
hub,
transport_stats,
invite_id,
secret,
profile,
@@ -1102,7 +1323,17 @@ async fn serve_one(
playback,
} => {
handle_hello(
stream, pool, service, hub, group_id, profile, devices, vector, ops, snapshot,
stream,
pool,
service,
hub,
transport_stats,
group_id,
profile,
devices,
vector,
ops,
snapshot,
playback,
)
.await
@@ -1117,6 +1348,7 @@ async fn handle_pair_request(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
invite_id: String,
secret: String,
mut profile: DeviceProfileWire,
@@ -1207,6 +1439,7 @@ async fn handle_pair_request(
)
.await?;
finish_response(&mut stream).await?;
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
return Ok(());
}
Some("accepted") => {}
@@ -1257,6 +1490,7 @@ async fn handle_pair_request(
)
.await?;
finish_response(&mut stream).await?;
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
Ok(())
}
@@ -1266,6 +1500,7 @@ async fn handle_hello(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
group_id: String,
mut profile: DeviceProfileWire,
devices: Vec<DeviceProfileWire>,
@@ -1326,6 +1561,7 @@ async fn handle_hello(
)
.await?;
finish_response(&mut stream).await?;
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
Ok(())
}
@@ -1333,6 +1569,7 @@ async fn sync_device(
pool: &sqlx::PgPool,
service: Arc<MusicDhtService>,
hub: Arc<PlayerDeviceHub>,
transport_stats: Arc<TransportStats>,
user_id: i64,
device: &StoredDevice,
) -> Result<()> {
@@ -1347,6 +1584,7 @@ async fn sync_device(
let snapshot = snapshot(pool, user_id).await?;
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
record_stream_transport(&transport_stats, "device-sync", "outbound", "open", &stream);
write_msg(
&mut stream,
&WireMessage::Hello {
@@ -1361,10 +1599,11 @@ async fn sync_device(
)
.await?;
finish_send(&mut stream).await?;
match read_msg(&mut stream)
let response = read_msg(&mut stream)
.await
.context("device sync response was not received")?
{
.context("device sync response was not received")?;
record_stream_transport(&transport_stats, "device-sync", "outbound", "done", &stream);
match response {
WireMessage::SyncResponse {
accepted: true,
devices,
@@ -1467,10 +1706,52 @@ async fn ensure_identity(pool: &sqlx::PgPool, user_id: i64, user_name: &str) ->
})
}
async fn maybe_seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> {
let last_seeded_at_ms: Option<i64> = sqlx::query_scalar(
"SELECT local_seeded_at_ms
FROM furumusic__fed_device_identity
WHERE user_id = $1",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
let Some(last_seeded_at_ms) = last_seeded_at_ms else {
return Ok(());
};
if now_ms().saturating_sub(last_seeded_at_ms) < LOCAL_SEED_RECHECK_MS
&& !local_seed_needs_metadata_backfill(pool, user_id).await?
{
return Ok(());
}
seed_local_user_state(pool, user_id).await
}
async fn local_seed_needs_metadata_backfill(pool: &sqlx::PgPool, user_id: i64) -> Result<bool> {
sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1 FROM furumusic__fed_state_like
WHERE user_id = $1
AND liked = true
AND fed_json IS NULL
AND local_track_id IS NOT NULL
) OR EXISTS (
SELECT 1 FROM furumusic__fed_state_playlist_item
WHERE user_id = $1
AND present = true
AND fed_json IS NULL
AND local_track_id IS NOT NULL
)",
)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(Into::into)
}
async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> {
let now = now_ms();
let like_rows = sqlx::query(
"SELECT ult.track_id, c.content_id
"SELECT ult.track_id, ult.created_at, c.content_id
FROM furumusic__user_liked_track ult
JOIN furumusic__track t ON t.id = ult.track_id
JOIN furumusic__media_file m ON m.id = t.audio_file_id
@@ -1487,17 +1768,25 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
continue;
};
let track_id: i64 = row.get("track_id");
let liked_hlc_ms = timestamp_from_iso(&row.get::<String, _>("created_at")).unwrap_or(now);
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
.await?
.map(serde_json::to_value)
.transpose()?;
sqlx::query(
"INSERT INTO furumusic__fed_state_like
(user_id, content_id, liked, hlc_ms, op_id, local_track_id, fed_json)
VALUES ($1, $2, true, $3, $4, $5, NULL)
ON CONFLICT (user_id, content_id) DO NOTHING",
VALUES ($1, $2, true, $3, $4, $5, $6)
ON CONFLICT (user_id, content_id) DO UPDATE SET
local_track_id = COALESCE(furumusic__fed_state_like.local_track_id, EXCLUDED.local_track_id),
fed_json = COALESCE(furumusic__fed_state_like.fed_json, EXCLUDED.fed_json)",
)
.bind(user_id)
.bind(&content_id)
.bind(now)
.bind(liked_hlc_ms)
.bind(format!("local_seed:like:{track_id}"))
.bind(track_id)
.bind(fed_json)
.execute(pool)
.await?;
}
@@ -1512,22 +1801,8 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
.await?;
for playlist in playlists {
let playlist_id: i64 = playlist.get("id");
let sync_id = format!("webpl_{user_id}_{playlist_id}");
let title: String = playlist.get("title");
sqlx::query(
"INSERT INTO furumusic__fed_state_playlist
(user_id, playlist_id, local_playlist_id, title, deleted, hlc_ms, op_id)
VALUES ($1, $2, $3, $4, false, $5, $6)
ON CONFLICT (user_id, playlist_id) DO NOTHING",
)
.bind(user_id)
.bind(&sync_id)
.bind(playlist_id)
.bind(&title)
.bind(now)
.bind(format!("local_seed:playlist:{playlist_id}"))
.execute(pool)
.await?;
let sync_id = ensure_seed_playlist_sync_id(pool, user_id, playlist_id, &title, now).await?;
let items = sqlx::query(
"SELECT pt.track_id, pt.position, c.content_id
@@ -1549,12 +1824,18 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
};
let track_id: i64 = item.get("track_id");
let position: i32 = item.get("position");
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
.await?
.map(serde_json::to_value)
.transpose()?;
sqlx::query(
"INSERT INTO furumusic__fed_state_playlist_item
(user_id, playlist_id, content_id, present, position, hlc_ms, op_id,
local_track_id, fed_json)
VALUES ($1, $2, $3, true, $4, $5, $6, $7, NULL)
ON CONFLICT (user_id, playlist_id, content_id) DO NOTHING",
VALUES ($1, $2, $3, true, $4, $5, $6, $7, $8)
ON CONFLICT (user_id, playlist_id, content_id) DO UPDATE SET
local_track_id = COALESCE(furumusic__fed_state_playlist_item.local_track_id, EXCLUDED.local_track_id),
fed_json = COALESCE(furumusic__fed_state_playlist_item.fed_json, EXCLUDED.fed_json)",
)
.bind(user_id)
.bind(&sync_id)
@@ -1563,6 +1844,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
.bind(now)
.bind(format!("local_seed:playlist_item:{playlist_id}:{track_id}"))
.bind(track_id)
.bind(fed_json)
.execute(pool)
.await?;
}
@@ -1593,17 +1875,16 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
.bind(user_id)
.fetch_one(pool)
.await?;
if missing_likes + missing_playlist_items == 0 {
sqlx::query(
"UPDATE furumusic__fed_device_identity
SET local_seeded_at_ms = $2
WHERE user_id = $1 AND local_seeded_at_ms = 0",
)
.bind(user_id)
.bind(now)
.execute(pool)
.await?;
} else {
sqlx::query(
"UPDATE furumusic__fed_device_identity
SET local_seeded_at_ms = $2
WHERE user_id = $1",
)
.bind(user_id)
.bind(now)
.execute(pool)
.await?;
if missing_likes + missing_playlist_items != 0 {
tracing::debug!(
user_id,
missing_likes,
@@ -1917,9 +2198,108 @@ async fn apply_op(
.await?;
Ok(false)
}
SyncOpPayload::ListenRecorded { event } => {
apply_listen_event(pool, user_id, &op.origin_device_id, event).await
}
}
}
async fn apply_listen_event(
pool: &sqlx::PgPool,
user_id: i64,
origin_device_id: &str,
event: &ListenEvent,
) -> Result<bool> {
if !event.should_record() || origin_device_id.trim().is_empty() {
return Ok(false);
}
let content_id =
music_dht::normalize_content_id(&event.content_id).context("invalid listen content id")?;
let local_track_id: Option<i64> = sqlx::query_scalar(
"SELECT local_track_id
FROM furumusic__track_ref
WHERE content_id = $1",
)
.bind(&content_id)
.fetch_optional(pool)
.await?
.flatten();
let inserted = sqlx::query(
"INSERT INTO furumusic__listen_event
(user_id, listen_id, content_id, local_track_id, origin_device_id,
started_at_ms, listened_ms, track_duration_ms, ended_reason,
qualified, metadata_json, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, listen_id) DO NOTHING",
)
.bind(user_id)
.bind(&event.listen_id)
.bind(&content_id)
.bind(local_track_id)
.bind(origin_device_id)
.bind(event.started_at_ms)
.bind(event.listened_ms)
.bind(event.track_duration_ms)
.bind(serde_json::to_string(&event.ended_reason)?)
.bind(event.qualifies_as_play())
.bind(serde_json::to_value(&event.track)?)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(pool)
.await?
.rows_affected()
> 0;
if !inserted || !event.qualifies_as_play() {
return Ok(inserted);
}
let Some(artist_name) = event
.track
.artist_names
.iter()
.find(|name| !name.trim().is_empty())
else {
return Ok(true);
};
let duration_seconds = event
.track_duration_ms
.unwrap_or_default()
.div_euclid(1_000)
.clamp(0, i64::from(i32::MAX)) as i32;
if duration_seconds <= 30 {
return Ok(true);
}
let listened_seconds = event
.listened_ms
.div_euclid(1_000)
.clamp(0, i64::from(i32::MAX)) as i32;
let started_at = event.started_at_ms.div_euclid(1_000);
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
sqlx::query(
"INSERT INTO furumusic__lastfm_scrobble_outbox
(user_id, track_id, started_at, listened_seconds, duration_seconds,
status, created_at, updated_at, dedupe_key, track_title,
artist_name, album_title)
SELECT $1, $2, $3, $4, $5, 'pending', $6, $6, $7, $8, $9, $10
WHERE EXISTS (
SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1
)
ON CONFLICT (dedupe_key) DO NOTHING",
)
.bind(user_id)
.bind(local_track_id)
.bind(started_at)
.bind(listened_seconds)
.bind(duration_seconds)
.bind(&now)
.bind(format!("listen:{user_id}:{}", event.listen_id))
.bind(&event.track.title)
.bind(artist_name)
.bind(event.track.release_title.as_deref())
.execute(pool)
.await?;
Ok(true)
}
async fn apply_like_state(
pool: &sqlx::PgPool,
user_id: i64,
@@ -2566,6 +2946,31 @@ pub async fn record_web_active_transfer(
Ok(())
}
pub async fn record_web_active_takeover(
pool: &sqlx::PgPool,
user_id: i64,
previous_device_id: &str,
state: serde_json::Value,
) -> Result<()> {
ensure_web_playback_target(pool, user_id, previous_device_id).await?;
let identity = ensure_identity(pool, user_id, "").await?;
let wire = playback_state_from_browser_json(pool, state).await?;
record_local_op(
pool,
user_id,
SyncOpPayload::PlaybackCommand {
target_device_id: previous_device_id.to_string(),
command: PlaybackCommand::ActiveChanged {
active_device_id: identity.device_id,
active_device_name: identity.name,
state: wire,
},
},
)
.await?;
Ok(())
}
async fn ensure_web_playback_target(
pool: &sqlx::PgPool,
user_id: i64,
@@ -2985,6 +3390,10 @@ fn json_f64(value: f64) -> serde_json::Value {
.unwrap_or_else(|| serde_json::json!(0.0))
}
fn cover_variant_url(file_id: Option<i64>, variant: &str) -> Option<String> {
file_id.map(|id| format!("/api/player/cover/{id}/{variant}"))
}
async fn web_playback_payload(
pool: &sqlx::PgPool,
state: &PlaybackStateWire,
@@ -3216,7 +3625,8 @@ async fn web_track_json_by_id(
"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
t.duration_seconds, r.id AS release_id, r.title::text AS release_title,
r.year AS release_year, mf.audio_format, mf.audio_bitrate,
mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes,
COALESCE(t.cover_file_id, r.cover_file_id) AS cover_file_id
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
@@ -3241,7 +3651,7 @@ async fn web_track_json_by_id(
"release_id": row.get::<i64, _>("release_id"),
"release_title": row.get::<String, _>("release_title"),
"release_year": row.get::<Option<i32>, _>("release_year"),
"cover_url": serde_json::Value::Null,
"cover_url": cover_variant_url(row.get::<Option<i64>, _>("cover_file_id"), "medium"),
"stream_url": format!("/api/player/stream/{track_id}"),
"uploader_name": "Fed",
"audio_format": row.get::<Option<String>, _>("audio_format"),
@@ -3274,6 +3684,21 @@ async fn track_artist_json(
.collect())
}
async fn track_artist_names(pool: &sqlx::PgPool, track_id: i64, role: &str) -> Result<Vec<String>> {
let rows = sqlx::query(
"SELECT a.name::text AS name
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = $1 AND ta.role = $2
ORDER BY ta.position",
)
.bind(track_id)
.bind(role)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|row| row.get("name")).collect())
}
fn placeholder_playback_track(
track: &PlaybackTrack,
index: usize,
@@ -3387,6 +3812,45 @@ async fn track_content_id(pool: &sqlx::PgPool, track_id: i64) -> Result<Option<S
Ok(value.and_then(|value| music_dht::normalize_content_id(&value)))
}
async fn synced_fed_track_for_track(
pool: &sqlx::PgPool,
track_id: i64,
content_id: &str,
) -> Result<Option<SyncedFedTrack>> {
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
return Ok(None);
};
let row = sqlx::query(
"SELECT t.title::text AS title, t.track_number, t.disc_number,
t.duration_seconds, r.title::text AS release_title, r.year AS release_year
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?;
let Some(row) = row else {
return Ok(None);
};
let duration = row.get::<f64, _>("duration_seconds");
Ok(Some(SyncedFedTrack {
item_id: format!("web:{track_id}"),
owner: "WEB".to_string(),
title: row.get("title"),
artist_names: track_artist_names(pool, track_id, "main").await?,
featured_artist_names: track_artist_names(pool, track_id, "featuring").await?,
year: row.get("release_year"),
duration_seconds: duration
.is_finite()
.then(|| duration.round().max(0.0) as i64),
content_id,
release_title: row.get("release_title"),
track_number: row.get("track_number"),
disc_number: row.get("disc_number"),
}))
}
async fn ensure_materialized_playlist(
pool: &sqlx::PgPool,
user_id: i64,
@@ -3414,17 +3878,36 @@ async fn ensure_materialized_playlist(
.bind(&now)
.fetch_one(pool)
.await?;
upsert_materialized_playlist_link(pool, user_id, playlist_sync_id, id, title).await?;
Ok(id)
}
async fn upsert_materialized_playlist_link(
pool: &sqlx::PgPool,
user_id: i64,
playlist_sync_id: &str,
local_playlist_id: i64,
title: &str,
) -> Result<()> {
sqlx::query(
"UPDATE furumusic__fed_state_playlist
SET local_playlist_id = $3
WHERE user_id = $1 AND playlist_id = $2",
"INSERT INTO furumusic__fed_state_playlist
(user_id, playlist_id, local_playlist_id, title, deleted, hlc_ms, op_id)
VALUES ($1, $2, $3, $4, false, 0, $5)
ON CONFLICT (user_id, playlist_id) DO UPDATE SET
local_playlist_id = COALESCE(furumusic__fed_state_playlist.local_playlist_id, EXCLUDED.local_playlist_id),
title = CASE
WHEN furumusic__fed_state_playlist.hlc_ms = 0 THEN EXCLUDED.title
ELSE furumusic__fed_state_playlist.title
END",
)
.bind(user_id)
.bind(playlist_sync_id)
.bind(id)
.bind(local_playlist_id)
.bind(title)
.bind(format!("local_materialized:{local_playlist_id}"))
.execute(pool)
.await?;
Ok(id)
Ok(())
}
async fn ensure_playlist_for_item(
@@ -3449,6 +3932,40 @@ async fn ensure_playlist_for_item(
ensure_materialized_playlist(pool, user_id, playlist_sync_id, None, &title).await
}
async fn ensure_seed_playlist_sync_id(
pool: &sqlx::PgPool,
user_id: i64,
playlist_id: i64,
title: &str,
hlc_ms: i64,
) -> Result<String> {
if let Some(sync_id) = local_playlist_sync_id(pool, user_id, playlist_id).await? {
return Ok(sync_id);
}
let sync_id = format!("webpl_{user_id}_{playlist_id}");
sqlx::query(
"INSERT INTO furumusic__fed_state_playlist
(user_id, playlist_id, local_playlist_id, title, deleted, hlc_ms, op_id)
VALUES ($1, $2, $3, $4, false, $5, $6)
ON CONFLICT (user_id, playlist_id) DO UPDATE SET
local_playlist_id = COALESCE(furumusic__fed_state_playlist.local_playlist_id, EXCLUDED.local_playlist_id),
title = CASE
WHEN furumusic__fed_state_playlist.hlc_ms = 0 THEN EXCLUDED.title
ELSE furumusic__fed_state_playlist.title
END,
deleted = false",
)
.bind(user_id)
.bind(&sync_id)
.bind(playlist_id)
.bind(title)
.bind(hlc_ms)
.bind(format!("local_seed:playlist:{playlist_id}"))
.execute(pool)
.await?;
Ok(sync_id)
}
async fn ensure_local_playlist_sync_id(
pool: &sqlx::PgPool,
user_id: i64,
@@ -3524,9 +4041,9 @@ async fn playlist_track_content_positions(
pool: &sqlx::PgPool,
playlist_id: i64,
track_ids: &[i64],
) -> Result<Vec<(String, i64)>> {
) -> Result<Vec<(i64, String, i64)>> {
let rows = sqlx::query(
"SELECT c.content_id, pt.position
"SELECT pt.track_id, c.content_id, pt.position
FROM furumusic__playlist_track pt
JOIN furumusic__track t ON t.id = pt.track_id
JOIN furumusic__media_file m ON m.id = t.audio_file_id
@@ -3542,9 +4059,10 @@ async fn playlist_track_content_positions(
Ok(rows
.into_iter()
.filter_map(|row| {
let track_id: i64 = row.get("track_id");
let content_id: String = row.get("content_id");
music_dht::normalize_content_id(&content_id)
.map(|content_id| (content_id, row.get::<i32, _>("position") as i64))
.map(|content_id| (track_id, content_id, row.get::<i32, _>("position") as i64))
})
.collect())
}
@@ -3707,6 +4225,7 @@ async fn ops_for_peer(
}
async fn snapshot(pool: &sqlx::PgPool, user_id: i64) -> Result<SyncSnapshot> {
maybe_seed_local_user_state(pool, user_id).await?;
let like_rows = sqlx::query(
"SELECT content_id, liked, hlc_ms, op_id, fed_json
FROM furumusic__fed_state_like
@@ -4041,6 +4560,7 @@ fn payload_kind(payload: &SyncOpPayload) -> &'static str {
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
SyncOpPayload::ListenRecorded { .. } => "listen_recorded",
}
}
@@ -4156,6 +4676,12 @@ fn iso_from_ms(ms: i64) -> String {
.to_string()
}
fn timestamp_from_iso(value: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|value| value.timestamp_millis())
}
fn now_label() -> String {
let secs = (now_ms() / 1000).max(0);
format!(
+209 -7
View File
@@ -9,22 +9,24 @@
//! or download from other peers.
//!
//! Settings are the regular admin config entries (`federation_enabled`,
//! `federation_network_id`) and apply on the fly — saving the settings
//! `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};
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::{
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats,
RendezvousConfig, SyncStats,
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
};
use serde_json::{Value, json};
use sqlx::PgPool;
@@ -37,6 +39,7 @@ 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>,
@@ -50,17 +53,180 @@ struct ContentHashJob {
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 {
@@ -81,12 +247,17 @@ pub fn handle() -> Arc<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()),
})
}))
}
@@ -131,7 +302,8 @@ impl Federation {
let mut effective = config.clone();
let rows = sqlx::query(
"SELECT key, value FROM furumusic__config_entry
WHERE key IN ('federation_enabled', 'federation_network_id', 'agent_storage_dir')",
WHERE key IN ('federation_enabled', 'federation_network_id',
'federation_save_on_listen', 'agent_storage_dir')",
)
.fetch_all(&pool)
.await
@@ -150,6 +322,11 @@ impl Federation {
}
}
"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);
}
@@ -164,6 +341,10 @@ impl Federation {
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 {
@@ -189,6 +370,7 @@ impl Federation {
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)
@@ -236,6 +418,7 @@ impl Federation {
pool.clone(),
storage_dir.clone(),
service.endpoint_id(),
Arc::clone(&self.transport_stats),
));
let catalog_acceptor = service
.stream_acceptor(CATALOG_ALPN)
@@ -245,6 +428,7 @@ impl Federation {
pool.clone(),
storage_dir,
service.endpoint_id(),
Arc::clone(&self.transport_stats),
));
let device_acceptor = service
.stream_acceptor(devices::SYNC_ALPN)
@@ -255,9 +439,14 @@ impl Federation {
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),
));
let device_sync_task =
tokio::spawn(devices::sync_loop(pool, Arc::clone(&service), device_hub));
*guard = Some(Running {
service,
@@ -596,6 +785,7 @@ impl Federation {
"connected_peers": peers,
"known_contacts": service.known_peers().len(),
"published_items": published,
"transport": self.transport_stats.snapshot(),
})
}
None => json!({ "running": false }),
@@ -668,6 +858,7 @@ impl Federation {
&pool,
service,
crate::player::PlayerDeviceHub::shared(),
Arc::clone(&self.transport_stats),
user_id,
user_name,
invite,
@@ -698,6 +889,7 @@ impl Federation {
&pool,
service,
crate::player::PlayerDeviceHub::shared(),
Arc::clone(&self.transport_stats),
user_id,
)
.await
@@ -740,6 +932,16 @@ impl Federation {
)
.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(
File diff suppressed because it is too large Load Diff
+143 -61
View File
@@ -3,18 +3,24 @@
//! with the furumi TUI client and any other furumi peer.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor};
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";
/// ALPN of the per-artist catalog protocol.
pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1";
/// Maximum size of a JSON protocol line (request or response header).
const MAX_PROTOCOL_LINE: usize = 4096;
@@ -63,58 +69,6 @@ struct TrackMetadata {
disc_number: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct CatalogRequest {
artist: String,
#[serde(default)]
want: Option<String>,
#[serde(default)]
release: Option<String>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogResponse {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
artist: Option<CatalogArtist>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogArtist {
name: String,
releases: Vec<CatalogRelease>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogRelease {
title: String,
release_type: String,
year: Option<i32>,
tracks: Vec<CatalogTrack>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogTrack {
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
content_id: Option<String>,
item_id: String,
}
#[derive(Debug, Default, Serialize)]
struct ImageHeader {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
mime_type: String,
size: u64,
}
// ---------------------------------------------------------------------------
// Framing helpers
// ---------------------------------------------------------------------------
@@ -274,6 +228,31 @@ async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<
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),
@@ -344,13 +323,16 @@ pub async fn serve_audio(
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).await {
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).await
{
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
}
});
@@ -362,7 +344,9 @@ async fn serve_audio_one(
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,
@@ -465,6 +449,7 @@ async fn serve_audio_one(
// 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(())
}
@@ -493,13 +478,17 @@ pub async fn serve_catalog(
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).await {
if let Err(err) =
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).await
{
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
}
});
@@ -511,7 +500,9 @@ async fn serve_catalog_one(
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,
@@ -527,7 +518,23 @@ async fn serve_catalog_one(
Err(err) => CatalogResponse {
ok: false,
error: Some(format!("catalog lookup failed: {err:#}")),
artist: None,
..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
@@ -569,7 +576,7 @@ async fn serve_catalog_one(
let response = CatalogResponse {
ok: false,
error: Some(format!("unknown request kind '{other}'")),
artist: None,
..CatalogResponse::default()
};
stream
.send
@@ -579,6 +586,7 @@ async fn serve_catalog_one(
}
stream.send.finish()?;
let _ = stream.send.stopped().await;
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
Ok(())
}
@@ -595,7 +603,7 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
return Ok(CatalogResponse {
ok: false,
error: Some("artist not found in the library".to_string()),
artist: None,
..CatalogResponse::default()
});
};
let artist_id: i64 = artist_row.get(0);
@@ -630,8 +638,11 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
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),
@@ -649,11 +660,82 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
Ok(CatalogResponse {
ok: true,
error: None,
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()
})
}
+5 -5
View File
@@ -447,17 +447,17 @@ async fn fetch_pending_scrobbles(
o.duration_seconds,
o.attempt_count,
a.session_key::text AS session_key,
t.title::text AS title,
r.title::text AS album_title,
COALESCE(o.track_title, t.title::text) AS title,
COALESCE(o.album_title, r.title::text) AS album_title,
t.track_number,
(
COALESCE(o.artist_name, (
SELECT ar.name::text
FROM furumusic__track_artist ta
JOIN furumusic__artist ar ON ar.id = ta.artist_id
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
ORDER BY ta.position
LIMIT 1
) AS artist_name,
)) AS artist_name,
(
SELECT ar.name::text
FROM furumusic__release_artist ra
@@ -468,7 +468,7 @@ async fn fetch_pending_scrobbles(
) AS album_artist_name
FROM furumusic__lastfm_scrobble_outbox o
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
JOIN furumusic__track t ON t.id = o.track_id
LEFT JOIN furumusic__track t ON t.id = o.track_id
LEFT JOIN furumusic__release r ON r.id = t.release_id
WHERE o.user_id = $1
AND o.status IN ('pending', 'retry')
+213
View File
@@ -2223,6 +2223,217 @@ pub mod db_migrations {
&[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()];
}
#[cot::db::migrations::migration_op]
async fn create_synced_listen_history(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__listen_event (
user_id BIGINT NOT NULL,
listen_id TEXT NOT NULL,
content_id TEXT NOT NULL,
local_track_id BIGINT,
origin_device_id TEXT NOT NULL,
started_at_ms BIGINT NOT NULL,
listened_ms BIGINT NOT NULL,
track_duration_ms BIGINT,
ended_reason TEXT NOT NULL,
qualified BOOLEAN NOT NULL,
metadata_json JSONB NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, listen_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_listen_event_user_time
ON furumusic__listen_event (user_id, started_at_ms DESC, listen_id)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_listen_event_content
ON furumusic__listen_event (user_id, content_id)",
)
.await?;
ctx.db
.raw(
"INSERT INTO furumusic__listen_event
(user_id, listen_id, content_id, local_track_id,
origin_device_id, started_at_ms, listened_ms,
track_duration_ms, ended_reason, qualified,
metadata_json, created_at)
SELECT ph.user_id,
'legacy-web:' || ph.id::text,
tr.content_id,
ph.track_id,
ident.device_id,
(EXTRACT(EPOCH FROM ph.played_at::timestamptz) * 1000)::bigint,
COALESCE(ph.duration_listened, 0)::bigint * 1000,
(t.duration_seconds * 1000)::bigint,
CASE WHEN ph.completed THEN '\"finished\"' ELSE '\"unknown\"' END,
ph.completed,
jsonb_build_object(
'title', t.title::text,
'artist_names', COALESCE((
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = t.id
AND ta.role <> 'featuring'
), '[]'::jsonb),
'featured_artist_names', COALESCE((
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = t.id
AND ta.role = 'featuring'
), '[]'::jsonb),
'release_title', r.title::text
),
ph.played_at::text
FROM furumusic__play_history ph
JOIN furumusic__track t ON t.id = ph.track_id
JOIN furumusic__track_ref tr ON tr.local_track_id = ph.track_id
JOIN furumusic__fed_device_identity ident
ON ident.user_id = ph.user_id
LEFT JOIN furumusic__release r ON r.id = t.release_id
ON CONFLICT (user_id, listen_id) DO NOTHING",
)
.await?;
ctx.db
.raw(
"ALTER TABLE furumusic__lastfm_scrobble_outbox
ALTER COLUMN track_id DROP NOT NULL",
)
.await?;
ctx.db
.raw(
"ALTER TABLE furumusic__lastfm_scrobble_outbox
ADD COLUMN IF NOT EXISTS track_title TEXT,
ADD COLUMN IF NOT EXISTS artist_name TEXT,
ADD COLUMN IF NOT EXISTS album_title TEXT",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0041CreateSyncedListenHistory;
impl migrations::Migration for M0041CreateSyncedListenHistory {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0041_create_synced_listen_history";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0040_create_content_addressed_music_refs",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_synced_listen_history).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2253,5 +2464,7 @@ pub mod db_migrations {
&M0037CreatePlaylistShareLinks,
&M0038CreateFedDeviceSync,
&M0039EnsureFederationContentIdCache,
&M0040CreateContentAddressedMusicRefs,
&M0041CreateSyncedListenHistory,
];
}
+48 -3
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>,
@@ -77,6 +78,8 @@ pub(super) struct TrackItem {
#[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,
}
@@ -538,14 +541,16 @@ pub(super) struct UserUploadReviewUpdateRequest {
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayHistoryItem {
pub(super) id: i64,
pub(super) track_id: i64,
pub(super) id: String,
pub(super) track_id: Option<i64>,
pub(super) track_title: String,
pub(super) release_title: Option<String>,
pub(super) track: TrackItem,
pub(super) track: serde_json::Value,
pub(super) played_at: String,
pub(super) duration_listened: Option<i32>,
pub(super) completed: bool,
pub(super) device_id: String,
pub(super) device_name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -561,6 +566,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: Option<String>,
pub(super) item_id: Option<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>,
+1060 -209
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -3,9 +3,11 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(super) struct HistoryEntry {
pub(super) track_id: i64,
pub(super) listen_id: Option<String>,
pub(super) started_at: Option<i64>,
pub(super) duration_listened: Option<i32>,
pub(super) completed: bool,
pub(super) ended_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
-28
View File
@@ -254,34 +254,6 @@ pub(super) struct ReleaseUploaderRow {
pub(super) track_count: i64,
}
#[derive(sqlx::FromRow)]
pub(super) struct PlayHistoryTrackRow {
pub(super) history_id: i64,
pub(super) played_at: String,
pub(super) duration_listened: Option<i32>,
pub(super) completed: bool,
pub(super) id: i64,
pub(super) title: String,
pub(super) track_number: Option<i32>,
pub(super) disc_number: Option<i32>,
pub(super) duration_seconds: f64,
pub(super) cover_file_id: Option<i64>,
pub(super) release_cover_file_id: Option<i64>,
pub(super) release_id: i64,
pub(super) release_title: String,
pub(super) release_year: Option<i32>,
pub(super) uploader_name: String,
pub(super) audio_format: Option<String>,
pub(super) audio_bitrate: Option<i32>,
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(super) struct ReleaseInfoRow {
pub(super) id: i64,
+51 -1
View File
@@ -2293,6 +2293,17 @@ tbody tr:hover {
<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">
@@ -2303,6 +2314,29 @@ tbody tr:hover {
<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">
@@ -2901,7 +2935,8 @@ function adminV2() {
agent_context_limit: '',
agent_concurrency: '',
federation_enabled: false,
federation_network_id: ''
federation_network_id: '',
federation_save_on_listen: false
},
settingsProbe: { status: 'idle', ok: false },
settingsProbeLoading: false,
@@ -3268,6 +3303,21 @@ function adminV2() {
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 {
+144 -1
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()">
@@ -683,7 +821,12 @@
</template>
</button>
<div class="track-info">
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
<div class="track-title">
<span x-text="item.track?.title || item.track_title"></span>
<span class="history-device-badge"
:title="item.device_id"
x-text="item.device_name"></span>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
File diff suppressed because it is too large Load Diff
+471 -125
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,143 @@
</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.federationFailure(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.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
<span class="federation-error-badge"
:title="$store.library.federationFailure(track.key.content_id).message">!</span>
</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 +710,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 +721,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 +741,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 +841,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 +867,124 @@
</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.federationFailure(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.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
<span class="federation-error-badge"
:title="$store.library.federationFailure(track.key.content_id).message">!</span>
</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 +1072,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 +1088,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 +1124,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 +1161,50 @@
<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.federationFailure(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.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
<span class="federation-error-badge"
:title="$store.library.federationFailure(track.content_id).message">!</span>
</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 +1214,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 +1234,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>
@@ -1012,7 +1320,37 @@
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<span class="track-num" x-text="idx + 1"></span>
<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.federationFailure(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.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
<span class="federation-error-badge"
:title="$store.library.federationFailure(track.content_id).message">!</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 playlist-track-info">
<button class="playlist-drag-handle"
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
@@ -1030,7 +1368,9 @@
<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>
@@ -1041,14 +1381,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>
@@ -1059,7 +1404,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>
@@ -1118,6 +1465,35 @@
<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) && !$store.library.federationFailure(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.federationFailure(item.track.content_id)">
<span class="federation-error-badge"
:title="$store.library.federationFailure(item.track.content_id).message">!</span>
</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>
@@ -1125,7 +1501,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>
@@ -1134,7 +1512,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>
@@ -1305,7 +1683,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)">
@@ -1335,67 +1714,34 @@
</span>
</button>
</template>
<div class="device-section-label fed-section-label">Fed Clients</div>
<div class="fed-device-panel">
<div class="fed-device-status" x-text="$store.devices.fedSummary()"></div>
<template x-if="$store.devices.fedError">
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
</template>
<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 in another sync group' : (request.client_version || 'waiting for approval')"></div>
<template x-if="request.requester_group_id">
<div class="fed-pairing-note">Recommended keeps the existing group intact.</div>
</template>
<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 ? 'Recommended' : 'Approve'"></button>
<button class="fed-action-btn"
@click="$store.devices.answerFedPairing(request, false, false)">Cancel</button>
</div>
</div>
</template>
<template x-for="device in $store.devices.fedDevices()" :key="device.device_id">
<div class="fed-device-row" :class="{ self: device.is_self }">
<span class="fed-device-dot" :class="{ self: device.is_self }"></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 ? 'WEB' : (device.client_version || 'unknown'))"></span>
</span>
<button class="fed-revoke-btn"
x-show="!device.is_self"
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
</div>
</template>
<div class="fed-device-actions">
<button class="fed-action-btn primary"
:disabled="$store.devices.fedBusy"
@click="$store.devices.generateFedInvite()">Invite</button>
<button class="fed-action-btn"
:disabled="$store.devices.fedBusy"
@click="$store.devices.syncFedDevices()">Sync</button>
<template x-if="$store.devices.playbackFedDevices().length > 0">
<div class="device-group-divider">
<span>Trusted federation devices</span>
</div>
<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="fed-action-btn"
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
@click="$store.devices.connectFedInvite()">Connect</button>
</div>
</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>
+306 -6
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;
@@ -1357,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;
@@ -1955,6 +1991,33 @@ 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;
@@ -2436,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;
@@ -2632,6 +2702,125 @@ 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-error-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid rgba(235, 105, 105, .7);
border-radius: 999px;
background: rgba(190, 55, 55, .18);
color: #ffb0b0;
font-size: 11px;
font-weight: 850;
line-height: 1;
cursor: help;
}
.queue-federation-status .federation-error-badge {
width: 16px;
height: 16px;
font-size: 10px;
}
.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;
@@ -2675,7 +2864,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 {
@@ -3371,6 +3560,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;
@@ -3413,6 +3701,18 @@ button.user-stat:hover {
}
.history-row:last-child { border-bottom: 0; }
.history-device-badge {
display: inline-block;
margin-left: .45rem;
padding: .08rem .38rem;
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
border-radius: 999px;
color: var(--text-muted);
font-size: .68rem;
font-weight: 500;
line-height: 1.2;
vertical-align: .12rem;
}
.history-cover {
width: 40px;