Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd766cda24 | ||
|
|
49000f716c | ||
|
|
ee4990e2f1 | ||
|
|
e64b61c167 | ||
|
|
b737ced3fc | ||
|
|
c2bdd62a51 | ||
|
|
d1370c6a28 |
Generated
+12
-10
@@ -617,9 +617,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -1575,9 +1575,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -1718,7 +1718,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.6.7-fd"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3622,7 +3622,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -3633,6 +3633,7 @@ dependencies = [
|
|||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3765,12 +3766,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "netlink-proto"
|
name = "netlink-proto"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
|
checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"netlink-packet-core",
|
"netlink-packet-core",
|
||||||
"netlink-sys",
|
"netlink-sys",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.7.0"
|
version = "0.8.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+303
-5
@@ -12,18 +12,19 @@
|
|||||||
//! `federation_network_id`) and apply on the fly — saving the settings
|
//! `federation_network_id`) and apply on the fly — saving the settings
|
||||||
//! starts, stops or re-joins the node without a server restart.
|
//! starts, stops or re-joins the node without a server restart.
|
||||||
|
|
||||||
|
pub mod devices;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use music_dht::{
|
use music_dht::{
|
||||||
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats,
|
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||||
RendezvousConfig, SyncStats,
|
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -36,6 +37,7 @@ pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
|||||||
|
|
||||||
/// How often the published library is re-synchronized with the database.
|
/// How often the published library is re-synchronized with the database.
|
||||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
||||||
|
|
||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
@@ -49,6 +51,157 @@ struct ContentHashJob {
|
|||||||
file_path: String,
|
file_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 {
|
pub struct Federation {
|
||||||
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
@@ -60,6 +213,7 @@ pub struct Federation {
|
|||||||
running: tokio::sync::Mutex<Option<Running>>,
|
running: tokio::sync::Mutex<Option<Running>>,
|
||||||
last_sync: std::sync::Mutex<Option<String>>,
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
last_error: std::sync::Mutex<Option<String>>,
|
last_error: std::sync::Mutex<Option<String>>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_iso() -> String {
|
fn now_iso() -> String {
|
||||||
@@ -86,6 +240,7 @@ pub fn handle() -> Arc<Federation> {
|
|||||||
running: tokio::sync::Mutex::new(None),
|
running: tokio::sync::Mutex::new(None),
|
||||||
last_sync: std::sync::Mutex::new(None),
|
last_sync: std::sync::Mutex::new(None),
|
||||||
last_error: std::sync::Mutex::new(None),
|
last_error: std::sync::Mutex::new(None),
|
||||||
|
transport_stats: Arc::new(TransportStats::default()),
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -188,6 +343,7 @@ impl Federation {
|
|||||||
|
|
||||||
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
|
self.transport_stats.reset();
|
||||||
|
|
||||||
let config = MusicDhtConfig::builder()
|
let config = MusicDhtConfig::builder()
|
||||||
.data_dir(&self.data_dir)
|
.data_dir(&self.data_dir)
|
||||||
@@ -196,6 +352,7 @@ impl Federation {
|
|||||||
.rendezvous(RendezvousConfig::default())
|
.rendezvous(RendezvousConfig::default())
|
||||||
.stream_protocol(AUDIO_ALPN)
|
.stream_protocol(AUDIO_ALPN)
|
||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
|
.stream_protocol(devices::SYNC_ALPN)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
let (service, mut events) =
|
let (service, mut events) =
|
||||||
@@ -234,21 +391,47 @@ impl Federation {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
storage_dir.clone(),
|
storage_dir.clone(),
|
||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
let catalog_acceptor = service
|
let catalog_acceptor = service
|
||||||
.stream_acceptor(CATALOG_ALPN)
|
.stream_acceptor(CATALOG_ALPN)
|
||||||
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
||||||
let catalog_task = tokio::spawn(serve::serve_catalog(
|
let catalog_task = tokio::spawn(serve::serve_catalog(
|
||||||
catalog_acceptor,
|
catalog_acceptor,
|
||||||
pool,
|
pool.clone(),
|
||||||
storage_dir,
|
storage_dir,
|
||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
let device_acceptor = service
|
||||||
|
.stream_acceptor(devices::SYNC_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
||||||
|
let device_hub = crate::player::PlayerDeviceHub::shared();
|
||||||
|
let device_task = tokio::spawn(devices::serve_peers(
|
||||||
|
device_acceptor,
|
||||||
|
pool.clone(),
|
||||||
|
Arc::clone(&service),
|
||||||
|
Arc::clone(&device_hub),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
let device_sync_task = tokio::spawn(devices::sync_loop(
|
||||||
|
pool,
|
||||||
|
Arc::clone(&service),
|
||||||
|
device_hub,
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
network_name,
|
network_name,
|
||||||
tasks: vec![event_task, sync_task, audio_task, catalog_task],
|
tasks: vec![
|
||||||
|
event_task,
|
||||||
|
sync_task,
|
||||||
|
audio_task,
|
||||||
|
catalog_task,
|
||||||
|
device_task,
|
||||||
|
device_sync_task,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
@@ -575,6 +758,7 @@ impl Federation {
|
|||||||
"connected_peers": peers,
|
"connected_peers": peers,
|
||||||
"known_contacts": service.known_peers().len(),
|
"known_contacts": service.known_peers().len(),
|
||||||
"published_items": published,
|
"published_items": published,
|
||||||
|
"transport": self.transport_stats.snapshot(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => json!({ "running": false }),
|
None => json!({ "running": false }),
|
||||||
@@ -607,6 +791,120 @@ impl Federation {
|
|||||||
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||||||
Ok(peer.to_string())
|
Ok(peer.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_status(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<devices::FedDeviceStatus> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::status(&pool, user_id, user_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_invite(&self, user_id: i64, user_name: &str) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::create_invite(&pool, service, user_id, user_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_connect(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: &str,
|
||||||
|
invite: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let network_id = devices::invite_network_id(invite)?;
|
||||||
|
{
|
||||||
|
let guard = self.running.lock().await;
|
||||||
|
let Some(running) = guard.as_ref() else {
|
||||||
|
anyhow::bail!("federation is not running");
|
||||||
|
};
|
||||||
|
let expected = NetworkId::from_name(&running.network_name);
|
||||||
|
anyhow::ensure!(
|
||||||
|
network_id == expected,
|
||||||
|
"device invite belongs to a different federation network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::connect_invite(
|
||||||
|
&pool,
|
||||||
|
service,
|
||||||
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
user_id,
|
||||||
|
user_name,
|
||||||
|
invite,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_answer_pairing(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
request_id: &str,
|
||||||
|
accept: bool,
|
||||||
|
use_requester_group: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::answer_pairing(&pool, user_id, request_id, accept, use_requester_group).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_revoke(&self, user_id: i64, device_id: &str) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::revoke_device(&pool, user_id, device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_sync_now(&self, user_id: i64) -> Result<()> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::sync_once(
|
||||||
|
&pool,
|
||||||
|
service,
|
||||||
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_command(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
target_device_id: &str,
|
||||||
|
command: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
current_state: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_playback_command(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
target_device_id,
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
current_state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_active_transfer(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
target_device_id: &str,
|
||||||
|
previous_device_id: Option<&str>,
|
||||||
|
state: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_active_transfer(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
target_device_id,
|
||||||
|
previous_device_id,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_content_id(
|
async fn persist_content_id(
|
||||||
|
|||||||
+143
-61
@@ -3,18 +3,24 @@
|
|||||||
//! with the furumi TUI client and any other furumi peer.
|
//! with the furumi TUI client and any other furumi peer.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
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 serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use super::{TransportStats, record_stream_transport};
|
||||||
|
|
||||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
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).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
@@ -63,58 +69,6 @@ struct TrackMetadata {
|
|||||||
disc_number: Option<i32>,
|
disc_number: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct CatalogRequest {
|
|
||||||
artist: String,
|
|
||||||
#[serde(default)]
|
|
||||||
want: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
release: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogResponse {
|
|
||||||
ok: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
artist: Option<CatalogArtist>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogArtist {
|
|
||||||
name: String,
|
|
||||||
releases: Vec<CatalogRelease>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogRelease {
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
tracks: Vec<CatalogTrack>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogTrack {
|
|
||||||
title: String,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
duration_seconds: Option<f64>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
content_id: Option<String>,
|
|
||||||
item_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct ImageHeader {
|
|
||||||
ok: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
mime_type: String,
|
|
||||||
size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Framing helpers
|
// 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))))
|
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>> {
|
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||||
let Some(track) = sqlx::query(
|
let Some(track) = sqlx::query(
|
||||||
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
"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,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) {
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let storage_dir = storage_dir.clone();
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
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:#}");
|
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -362,7 +344,9 @@ async fn serve_audio_one(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
|
||||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = %stream.peer_id,
|
peer = %stream.peer_id,
|
||||||
@@ -465,6 +449,7 @@ async fn serve_audio_one(
|
|||||||
// Wait until the peer read everything before dropping the stream,
|
// Wait until the peer read everything before dropping the stream,
|
||||||
// otherwise the tail of the file is lost.
|
// otherwise the tail of the file is lost.
|
||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,13 +478,17 @@ pub async fn serve_catalog(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) {
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let storage_dir = storage_dir.clone();
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
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:#}");
|
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -511,7 +500,9 @@ async fn serve_catalog_one(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
|
||||||
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = %stream.peer_id,
|
peer = %stream.peer_id,
|
||||||
@@ -527,7 +518,23 @@ async fn serve_catalog_one(
|
|||||||
Err(err) => CatalogResponse {
|
Err(err) => CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
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
|
stream
|
||||||
@@ -569,7 +576,7 @@ async fn serve_catalog_one(
|
|||||||
let response = CatalogResponse {
|
let response = CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(format!("unknown request kind '{other}'")),
|
error: Some(format!("unknown request kind '{other}'")),
|
||||||
artist: None,
|
..CatalogResponse::default()
|
||||||
};
|
};
|
||||||
stream
|
stream
|
||||||
.send
|
.send
|
||||||
@@ -579,6 +586,7 @@ async fn serve_catalog_one(
|
|||||||
}
|
}
|
||||||
stream.send.finish()?;
|
stream.send.finish()?;
|
||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +603,7 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
|
|||||||
return Ok(CatalogResponse {
|
return Ok(CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some("artist not found in the library".to_string()),
|
error: Some("artist not found in the library".to_string()),
|
||||||
artist: None,
|
..CatalogResponse::default()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let artist_id: i64 = artist_row.get(0);
|
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 {
|
for row in track_rows {
|
||||||
let track_id: i64 = row.get(0);
|
let track_id: i64 = row.get(0);
|
||||||
let duration: f64 = row.get(4);
|
let duration: f64 = row.get(4);
|
||||||
|
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
|
||||||
tracks.push(CatalogTrack {
|
tracks.push(CatalogTrack {
|
||||||
title: row.get(1),
|
title: row.get(1),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
track_number: row.get(2),
|
track_number: row.get(2),
|
||||||
disc_number: row.get(3),
|
disc_number: row.get(3),
|
||||||
duration_seconds: (duration > 0.0).then_some(duration),
|
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(CatalogResponse {
|
||||||
ok: true,
|
ok: true,
|
||||||
error: None,
|
|
||||||
artist: Some(CatalogArtist {
|
artist: Some(CatalogArtist {
|
||||||
name: artist_row.get(1),
|
name: artist_row.get(1),
|
||||||
releases,
|
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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ translations! {
|
|||||||
player_cancel: "Cancel" , "Отмена";
|
player_cancel: "Cancel" , "Отмена";
|
||||||
player_create: "Create" , "Создать";
|
player_create: "Create" , "Создать";
|
||||||
player_save: "Save" , "Сохранить";
|
player_save: "Save" , "Сохранить";
|
||||||
|
player_done: "Done" , "Готово";
|
||||||
player_delete: "Delete" , "Удалить";
|
player_delete: "Delete" , "Удалить";
|
||||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
player_rename: "Rename" , "Переименовать";
|
player_rename: "Rename" , "Переименовать";
|
||||||
|
|||||||
@@ -1951,6 +1951,278 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_playlist_share_links).build()];
|
&[Operation::custom(create_playlist_share_links).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
device_id TEXT NOT NULL UNIQUE,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
local_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_sync TEXT,
|
||||||
|
last_error TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
client_version TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
endpoint_id TEXT NOT NULL DEFAULT '',
|
||||||
|
endpoint_ticket TEXT NOT NULL DEFAULT '',
|
||||||
|
trusted_at_ms BIGINT,
|
||||||
|
last_seen_ms BIGINT,
|
||||||
|
revoked_at_ms BIGINT,
|
||||||
|
revoked_by TEXT,
|
||||||
|
revoke_cutoff_seq BIGINT,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
|
||||||
|
ON furumusic__fed_device (device_id)
|
||||||
|
WHERE trusted_at_ms IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
|
||||||
|
invite_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
used_at_ms BIGINT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client_version TEXT NOT NULL,
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
endpoint_ticket TEXT NOT NULL,
|
||||||
|
invite_id TEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
answered_at_ms BIGINT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
requester_group_id TEXT,
|
||||||
|
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
|
||||||
|
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
use_requester_group BOOLEAN NOT NULL DEFAULT false
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
|
||||||
|
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
received_at_ms BIGINT NOT NULL,
|
||||||
|
tombstone BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
|
||||||
|
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
|
||||||
|
ON furumusic__fed_sync_ops (user_id, tombstone)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
peer_device_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
liked BOOLEAN NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
local_playlist_id BIGINT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, playlist_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
|
||||||
|
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
|
||||||
|
WHERE local_playlist_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
present BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
position BIGINT NOT NULL DEFAULT 0,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, playlist_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
|
||||||
|
ON furumusic__fed_state_playlist_item
|
||||||
|
(user_id, playlist_id, present, position)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
applied_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateFedDeviceSync;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateFedDeviceSync {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0037_create_playlist_share_links",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_fed_device_sync).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn ensure_federation_content_id_cache(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039EnsureFederationContentIdCache;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039EnsureFederationContentIdCache {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_fed_device_sync",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(ensure_federation_content_id_cache).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1979,5 +2251,7 @@ pub mod db_migrations {
|
|||||||
&M0035CreateEntityGenreTags,
|
&M0035CreateEntityGenreTags,
|
||||||
&M0036CreateExternalMetadataIds,
|
&M0036CreateExternalMetadataIds,
|
||||||
&M0037CreatePlaylistShareLinks,
|
&M0037CreatePlaylistShareLinks,
|
||||||
|
&M0038CreateFedDeviceSync,
|
||||||
|
&M0039EnsureFederationContentIdCache,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -74,6 +74,13 @@ pub(super) struct TrackItem {
|
|||||||
pub(super) lastfm_updated_at: Option<String>,
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistTrackItem {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) track: TrackItem,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct ArtistAppearanceTrack {
|
pub(super) struct ArtistAppearanceTrack {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -265,6 +272,24 @@ pub(super) struct PlayerDevicesResponse {
|
|||||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDeviceConnectRequest {
|
||||||
|
pub(super) invite: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDevicePairingAnswerRequest {
|
||||||
|
pub(super) request_id: String,
|
||||||
|
pub(super) accept: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) use_requester_group: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDeviceRevokeRequest {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayerDevicePollResponse {
|
pub(super) struct PlayerDevicePollResponse {
|
||||||
pub(super) device_id: String,
|
pub(super) device_id: String,
|
||||||
@@ -286,7 +311,7 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) is_public: bool,
|
pub(super) is_public: bool,
|
||||||
pub(super) is_saved: bool,
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
|||||||
+727
-23
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
use cot::http::StatusCode;
|
use cot::http::StatusCode;
|
||||||
@@ -59,6 +59,7 @@ const PLAYER_JAM_MAX_INVITEES: usize = 25;
|
|||||||
const PLAYER_RADIO_TRACK_LIMIT: usize = 40;
|
const PLAYER_RADIO_TRACK_LIMIT: usize = 40;
|
||||||
const PLAYER_RADIO_CANDIDATE_LIMIT: i64 = 220;
|
const PLAYER_RADIO_CANDIDATE_LIMIT: i64 = 220;
|
||||||
const PLAYER_RADIO_RELEASE_SEED_TRACKS: i64 = 4;
|
const PLAYER_RADIO_RELEASE_SEED_TRACKS: i64 = 4;
|
||||||
|
const FED_DEVICE_PREFIX: &str = "fed:";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PlayerDevice {
|
struct PlayerDevice {
|
||||||
@@ -108,11 +109,136 @@ struct PlayerDeviceHubState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct PlayerDeviceHub {
|
pub(crate) struct PlayerDeviceHub {
|
||||||
state: Mutex<PlayerDeviceHubState>,
|
state: Mutex<PlayerDeviceHubState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerDeviceHub {
|
impl PlayerDeviceHub {
|
||||||
|
pub(crate) fn shared() -> Arc<Self> {
|
||||||
|
static HUB: OnceLock<Arc<PlayerDeviceHub>> = OnceLock::new();
|
||||||
|
Arc::clone(HUB.get_or_init(|| Arc::new(PlayerDeviceHub::default())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn enqueue_fed_command(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
command: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
let now = current_millis();
|
||||||
|
let mut state = self.state.lock().expect("player device hub lock");
|
||||||
|
self.prune_locked(&mut state, now);
|
||||||
|
let devices = state
|
||||||
|
.devices_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.ok_or("no browser playback device")?;
|
||||||
|
let target = state
|
||||||
|
.active_device_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.filter(|id| !is_fed_virtual_device_id(id))
|
||||||
|
.filter(|id| devices.contains_key(*id))
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| {
|
||||||
|
devices
|
||||||
|
.values()
|
||||||
|
.filter(|device| !is_fed_virtual_device_id(&device.id))
|
||||||
|
.max_by_key(|device| device.last_seen_ms)
|
||||||
|
.map(|device| device.id.clone())
|
||||||
|
})
|
||||||
|
.ok_or("no browser playback device")?;
|
||||||
|
state.active_device_by_user.insert(user_id, target.clone());
|
||||||
|
self.enqueue_command_locked(&mut state, user_id, &target, command, payload, now);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_playback_state_json(&self, user_id: i64) -> Option<serde_json::Value> {
|
||||||
|
let state = self.state.lock().expect("player device hub lock");
|
||||||
|
if state
|
||||||
|
.active_device_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.is_some_and(|id| is_fed_virtual_device_id(id))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.playback_state_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.and_then(|playback| serde_json::to_value(playback).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn playback_state_json_for_commands(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let now = current_millis();
|
||||||
|
let state = self.state.lock().expect("player device hub lock");
|
||||||
|
state
|
||||||
|
.playback_state_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.cloned()
|
||||||
|
.map(|playback| playback_state_at(playback, now))
|
||||||
|
.and_then(|playback| serde_json::to_value(playback).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_device_id_for_commands(&self, user_id: i64) -> Option<String> {
|
||||||
|
let state = self.state.lock().expect("player device hub lock");
|
||||||
|
state.active_device_by_user.get(&user_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fed_device_name_for_commands(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
fed_device_id: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let virtual_id = fed_virtual_device_id(fed_device_id);
|
||||||
|
let state = self.state.lock().expect("player device hub lock");
|
||||||
|
state
|
||||||
|
.devices_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.and_then(|devices| devices.get(&virtual_id))
|
||||||
|
.map(|device| device.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_fed_playback_state_json(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
fed_device_id: &str,
|
||||||
|
fed_device_name: &str,
|
||||||
|
active: bool,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
let mut playback_state: PlayerDevicePlaybackStateDto =
|
||||||
|
serde_json::from_value(payload).map_err(|_| "invalid playback state")?;
|
||||||
|
let now = current_millis();
|
||||||
|
playback_state.updated_at_ms = now;
|
||||||
|
let virtual_id = fed_virtual_device_id(fed_device_id);
|
||||||
|
let mut state = self.state.lock().expect("player device hub lock");
|
||||||
|
self.prune_locked(&mut state, now);
|
||||||
|
state.devices_by_user.entry(user_id).or_default().insert(
|
||||||
|
virtual_id.clone(),
|
||||||
|
PlayerDevice {
|
||||||
|
id: virtual_id.clone(),
|
||||||
|
name: fed_device_name.to_string(),
|
||||||
|
kind: "fed".to_string(),
|
||||||
|
last_seen_ms: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let should_update_playback = active
|
||||||
|
|| state
|
||||||
|
.active_device_by_user
|
||||||
|
.get(&user_id)
|
||||||
|
.is_some_and(|active_id| active_id == &virtual_id);
|
||||||
|
if active {
|
||||||
|
state
|
||||||
|
.active_device_by_user
|
||||||
|
.insert(user_id, virtual_id.clone());
|
||||||
|
}
|
||||||
|
if should_update_playback {
|
||||||
|
state.playback_state_by_user.insert(user_id, playback_state);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn heartbeat(
|
fn heartbeat(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -194,7 +320,9 @@ impl PlayerDeviceHub {
|
|||||||
state
|
state
|
||||||
.playback_state_by_user
|
.playback_state_by_user
|
||||||
.insert(user_id, transfer_state.clone());
|
.insert(user_id, transfer_state.clone());
|
||||||
if let Ok(payload) = serde_json::to_value(transfer_state) {
|
if !is_fed_virtual_device_id(target_device_id)
|
||||||
|
&& let Ok(payload) = serde_json::to_value(transfer_state)
|
||||||
|
{
|
||||||
self.enqueue_command_locked(
|
self.enqueue_command_locked(
|
||||||
&mut state,
|
&mut state,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -772,6 +900,18 @@ fn current_millis() -> i64 {
|
|||||||
chrono::Utc::now().timestamp_millis()
|
chrono::Utc::now().timestamp_millis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fed_virtual_device_id(device_id: &str) -> String {
|
||||||
|
format!("{FED_DEVICE_PREFIX}{device_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_fed_virtual_device_id(device_id: &str) -> bool {
|
||||||
|
device_id.starts_with(FED_DEVICE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed_device_id_from_virtual(device_id: &str) -> Option<&str> {
|
||||||
|
device_id.strip_prefix(FED_DEVICE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
fn playback_state_at(
|
fn playback_state_at(
|
||||||
mut playback_state: PlayerDevicePlaybackStateDto,
|
mut playback_state: PlayerDevicePlaybackStateDto,
|
||||||
now: i64,
|
now: i64,
|
||||||
@@ -796,7 +936,7 @@ fn normalize_device_id(raw: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
if !trimmed
|
if !trimmed
|
||||||
.chars()
|
.chars()
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ':')
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -949,6 +1089,14 @@ mod device_tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(device_kind_from_user_agent(user_agent), "phone");
|
assert_eq!(device_kind_from_user_agent(user_agent), "phone");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_virtual_fed_device_ids() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_device_id("fed:dev_e5ffc3b65642770c26c53ecf"),
|
||||||
|
Some("fed:dev_e5ffc3b65642770c26c53ecf".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
@@ -3414,6 +3562,7 @@ async fn artist_detail_handler(
|
|||||||
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT * FROM (
|
r#"SELECT * FROM (
|
||||||
SELECT DISTINCT ON (lower(t.title::text))
|
SELECT DISTINCT ON (lower(t.title::text))
|
||||||
|
NULL::bigint AS playlist_track_id,
|
||||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
@@ -3515,7 +3664,8 @@ async fn release_detail_handler(
|
|||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3687,7 +3837,8 @@ async fn playlist_detail_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT pt.id AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3715,7 +3866,7 @@ async fn playlist_detail_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool).await?;
|
let track_items = build_playlist_track_items(tracks, pool).await?;
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: info.id,
|
id: info.id,
|
||||||
@@ -3813,13 +3964,34 @@ async fn build_track_items(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_playlist_track_items(
|
||||||
|
tracks: Vec<PlaylistTrackRow>,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> cot::Result<Vec<PlaylistTrackItem>> {
|
||||||
|
let playlist_track_ids = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.playlist_track_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let track_items = build_track_items(tracks, pool).await?;
|
||||||
|
|
||||||
|
Ok(track_items
|
||||||
|
.into_iter()
|
||||||
|
.zip(playlist_track_ids)
|
||||||
|
.map(|(track, playlist_track_id)| PlaylistTrackItem {
|
||||||
|
playlist_track_id,
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3976,7 +4148,8 @@ async fn likes_playlist_handler(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -4004,7 +4177,14 @@ async fn likes_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool).await?;
|
let track_items = build_track_items(tracks, pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| PlaylistTrackItem {
|
||||||
|
playlist_track_id: None,
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: -1,
|
id: -1,
|
||||||
@@ -4501,6 +4681,7 @@ async fn devices_select_handler(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(normalize_device_id)
|
.and_then(normalize_device_id)
|
||||||
.unwrap_or_else(|| target_device_id.clone());
|
.unwrap_or_else(|| target_device_id.clone());
|
||||||
|
let previous_active_id = hub.active_device_id_for_commands(user.id);
|
||||||
|
|
||||||
let Some(response) = hub.select(user.id, ¤t_device_id, &target_device_id) else {
|
let Some(response) = hub.select(user.id, ¤t_device_id, &target_device_id) else {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
@@ -4508,6 +4689,28 @@ async fn devices_select_handler(
|
|||||||
"target device is offline",
|
"target device is offline",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
if let Some(fed_device_id) = fed_device_id_from_virtual(&target_device_id) {
|
||||||
|
let Some(playback_state) = response.playback_state.clone() else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"no playback state to transfer",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let state = match serde_json::to_value(playback_state) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
};
|
||||||
|
let previous_fed_device_id = previous_active_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(fed_device_id_from_virtual)
|
||||||
|
.filter(|previous| *previous != fed_device_id);
|
||||||
|
if let Err(err) = crate::federation::handle()
|
||||||
|
.fed_device_web_active_transfer(user.id, fed_device_id, previous_fed_device_id, state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Json(response).into_response()
|
Json(response).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4552,6 +4755,33 @@ async fn devices_command_handler(
|
|||||||
stamp_jam_queue_tracks(&mut payload, user.id, &user.name);
|
stamp_jam_queue_tracks(&mut payload, user.id, &user.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if jam_id.is_none()
|
||||||
|
&& let Some(fed_device_id) = target_device_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(fed_device_id_from_virtual)
|
||||||
|
{
|
||||||
|
let current_state = hub.playback_state_json_for_commands(user.id);
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_web_command(user.id, fed_device_id, command, payload, current_state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(next_state) => {
|
||||||
|
let name = hub
|
||||||
|
.fed_device_name_for_commands(user.id, fed_device_id)
|
||||||
|
.unwrap_or_else(|| fed_device_id.to_string());
|
||||||
|
let _ = hub.apply_fed_playback_state_json(
|
||||||
|
user.id,
|
||||||
|
fed_device_id,
|
||||||
|
&name,
|
||||||
|
true,
|
||||||
|
next_state,
|
||||||
|
);
|
||||||
|
return Json(serde_json::json!({"ok": true})).into_response();
|
||||||
|
}
|
||||||
|
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match hub.enqueue_command(
|
match hub.enqueue_command(
|
||||||
user.id,
|
user.id,
|
||||||
target_device_id.as_deref(),
|
target_device_id.as_deref(),
|
||||||
@@ -4564,6 +4794,116 @@ async fn devices_command_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fed_devices_status_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_status(user.id, &user.name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(status) => Json(status).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fed_devices_invite_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_invite(user.id, &user.name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(invite) => Json(serde_json::json!({ "invite": invite })).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fed_devices_connect_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Json(dto): Json<FedDeviceConnectRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_connect(user.id, &user.name, dto.invite.trim())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(message) => Json(serde_json::json!({ "ok": true, "message": message })).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fed_devices_pairing_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Json(dto): Json<FedDevicePairingAnswerRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_answer_pairing(
|
||||||
|
user.id,
|
||||||
|
dto.request_id.trim(),
|
||||||
|
dto.accept,
|
||||||
|
dto.use_requester_group,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fed_devices_revoke_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Json(dto): Json<FedDeviceRevokeRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_revoke(user.id, dto.device_id.trim())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fed_devices_sync_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
match crate::federation::handle()
|
||||||
|
.fed_device_sync_now(user.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||||
|
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_name: &str) {
|
fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_name: &str) {
|
||||||
let Some(tracks) = payload
|
let Some(tracks) = payload
|
||||||
.get_mut("tracks")
|
.get_mut("tracks")
|
||||||
@@ -4586,6 +4926,103 @@ fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_na
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn record_fed_track_like(pool: &sqlx::PgPool, user_id: i64, track_id: i64, liked: bool) {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::federation::devices::record_track_like(pool, user_id, track_id, liked).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
track_id,
|
||||||
|
liked,
|
||||||
|
"fed device like op was not recorded: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_fed_playlist_created(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
playlist_id: i64,
|
||||||
|
title: &str,
|
||||||
|
) {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::federation::devices::record_playlist_created(pool, user_id, playlist_id, title).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
playlist_id,
|
||||||
|
"fed device playlist create op was not recorded: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_fed_playlist_renamed(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
playlist_id: i64,
|
||||||
|
title: &str,
|
||||||
|
) {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::federation::devices::record_playlist_renamed(pool, user_id, playlist_id, title).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
playlist_id,
|
||||||
|
"fed device playlist rename op was not recorded: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_fed_playlist_deleted(pool: &sqlx::PgPool, user_id: i64, playlist_id: i64) {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::federation::devices::record_playlist_deleted(pool, user_id, playlist_id).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
playlist_id,
|
||||||
|
"fed device playlist delete op was not recorded: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_fed_playlist_tracks_added(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
playlist_id: i64,
|
||||||
|
track_ids: &[i64],
|
||||||
|
) {
|
||||||
|
if let Err(err) = crate::federation::devices::record_playlist_tracks_added(
|
||||||
|
pool,
|
||||||
|
user_id,
|
||||||
|
playlist_id,
|
||||||
|
track_ids,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
playlist_id,
|
||||||
|
"fed device playlist add op was not recorded: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_fed_playlist_tracks_removed(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
playlist_id: i64,
|
||||||
|
content_ids: &[String],
|
||||||
|
) {
|
||||||
|
if let Err(err) = crate::federation::devices::record_playlist_tracks_removed(
|
||||||
|
pool,
|
||||||
|
user_id,
|
||||||
|
playlist_id,
|
||||||
|
content_ids,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
playlist_id,
|
||||||
|
"fed device playlist remove op was not recorded: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn jam_users_search_handler(
|
async fn jam_users_search_handler(
|
||||||
auth_ctx: auth::AuthContext,
|
auth_ctx: auth::AuthContext,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -5428,6 +5865,8 @@ async fn create_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
record_fed_playlist_created(pool, user.id, row.0, &title).await;
|
||||||
|
|
||||||
Json(PlaylistCard {
|
Json(PlaylistCard {
|
||||||
id: row.0,
|
id: row.0,
|
||||||
title,
|
title,
|
||||||
@@ -5481,6 +5920,7 @@ async fn update_playlist_handler(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
record_fed_playlist_renamed(pool, user.id, playlist_id, t).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(desc) = &body.description {
|
if let Some(desc) = &body.description {
|
||||||
@@ -5524,6 +5964,7 @@ async fn delete_playlist_handler(
|
|||||||
if owner.0 != user.id {
|
if owner.0 != user.id {
|
||||||
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
}
|
}
|
||||||
|
record_fed_playlist_deleted(pool, user.id, playlist_id).await;
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1")
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1")
|
||||||
.bind(playlist_id)
|
.bind(playlist_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -5606,6 +6047,125 @@ async fn add_tracks_to_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
record_fed_playlist_tracks_added(pool, user.id, playlist_id, &body.track_ids).await;
|
||||||
|
|
||||||
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/player/playlists/{id}/tracks — reorder playlist tracks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn reorder_playlist_tracks_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
path: Path<PathId>,
|
||||||
|
Json(body): Json<ReorderPlaylistRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
let playlist_id = path.0.id;
|
||||||
|
let owner: Option<(i64,)> =
|
||||||
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let Some(owner) = owner else {
|
||||||
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
||||||
|
};
|
||||||
|
if owner.0 != user.id {
|
||||||
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_ids = body
|
||||||
|
.playlist_track_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| *id > 0)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let unique_requested = requested_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
if unique_requested.len() != requested_ids.len() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"duplicate playlist track ids",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let visible_set = visible_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
if visible_set != unique_requested {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"playlist track ids do not match this playlist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hidden_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = true
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut ordered_ids = requested_ids;
|
||||||
|
ordered_ids.extend(hidden_ids);
|
||||||
|
if !ordered_ids.is_empty() {
|
||||||
|
sqlx::query(
|
||||||
|
r#"WITH ordered AS (
|
||||||
|
SELECT id, ord::integer - 1 AS position
|
||||||
|
FROM unnest($1::bigint[]) WITH ORDINALITY AS u(id, ord)
|
||||||
|
)
|
||||||
|
UPDATE furumusic__playlist_track pt
|
||||||
|
SET position = ordered.position
|
||||||
|
FROM ordered
|
||||||
|
WHERE pt.id = ordered.id AND pt.playlist_id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(&ordered_ids)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let reordered_track_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.track_id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
record_fed_playlist_tracks_added(pool, user.id, playlist_id, &reordered_track_ids).await;
|
||||||
|
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5637,13 +6197,47 @@ async fn remove_track_from_playlist_handler(
|
|||||||
if owner.0 != user.id {
|
if owner.0 != user.id {
|
||||||
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
}
|
}
|
||||||
|
let removed_content_ids = match crate::federation::devices::playlist_content_ids_for_removal(
|
||||||
|
pool,
|
||||||
|
playlist_id,
|
||||||
|
body.playlist_track_id,
|
||||||
|
body.track_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
playlist_id,
|
||||||
|
"fed device playlist removal lookup failed: {err:#}"
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2")
|
match (body.playlist_track_id, body.track_id) {
|
||||||
.bind(playlist_id)
|
(Some(playlist_track_id), _) => {
|
||||||
.bind(body.track_id)
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND id = $2")
|
||||||
.execute(pool)
|
.bind(playlist_id)
|
||||||
.await
|
.bind(playlist_track_id)
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
(None, Some(track_id)) => {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2",
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.bind(track_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "missing track id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-number positions
|
// Re-number positions
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -5660,6 +6254,16 @@ async fn remove_track_from_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
record_fed_playlist_tracks_removed(pool, user.id, playlist_id, &removed_content_ids).await;
|
||||||
|
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5694,6 +6298,7 @@ async fn toggle_like_track_handler(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
record_fed_track_like(pool, user.id, track_id, false).await;
|
||||||
Json(LikeStatus { liked: false }).into_response()
|
Json(LikeStatus { liked: false }).into_response()
|
||||||
} else {
|
} else {
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
@@ -5706,6 +6311,7 @@ async fn toggle_like_track_handler(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
record_fed_track_like(pool, user.id, track_id, true).await;
|
||||||
Json(LikeStatus { liked: true }).into_response()
|
Json(LikeStatus { liked: true }).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5726,16 +6332,17 @@ async fn like_release_handler(
|
|||||||
};
|
};
|
||||||
let release_id = path.0.id;
|
let release_id = path.0.id;
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
let track_ids = sqlx::query_scalar::<_, i64>(
|
||||||
// Check if ALL tracks in this release are already liked
|
"SELECT id FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
|
||||||
let total: (i64,) = sqlx::query_as(
|
|
||||||
"SELECT COUNT(*) FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
|
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.fetch_one(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
// Check if ALL tracks in this release are already liked
|
||||||
|
let total = track_ids.len() as i64;
|
||||||
|
|
||||||
let liked_count: (i64,) = sqlx::query_as(
|
let liked_count: (i64,) = sqlx::query_as(
|
||||||
r#"SELECT COUNT(*) FROM furumusic__user_liked_track ult
|
r#"SELECT COUNT(*) FROM furumusic__user_liked_track ult
|
||||||
JOIN furumusic__track t ON t.id = ult.track_id
|
JOIN furumusic__track t ON t.id = ult.track_id
|
||||||
@@ -5747,7 +6354,7 @@ async fn like_release_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
if liked_count.0 >= total.0 && total.0 > 0 {
|
if liked_count.0 >= total && total > 0 {
|
||||||
// Unlike all tracks in release
|
// Unlike all tracks in release
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"DELETE FROM furumusic__user_liked_track
|
r#"DELETE FROM furumusic__user_liked_track
|
||||||
@@ -5760,6 +6367,9 @@ async fn like_release_handler(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
for track_id in &track_ids {
|
||||||
|
record_fed_track_like(pool, user.id, *track_id, false).await;
|
||||||
|
}
|
||||||
Json(LikeStatus { liked: false }).into_response()
|
Json(LikeStatus { liked: false }).into_response()
|
||||||
} else {
|
} else {
|
||||||
// Like all tracks in release (skip already liked)
|
// Like all tracks in release (skip already liked)
|
||||||
@@ -5779,6 +6389,9 @@ async fn like_release_handler(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
for track_id in &track_ids {
|
||||||
|
record_fed_track_like(pool, user.id, *track_id, true).await;
|
||||||
|
}
|
||||||
Json(LikeStatus { liked: true }).into_response()
|
Json(LikeStatus { liked: true }).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6439,7 +7052,7 @@ impl PlayerApp {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
scheduler_handle,
|
scheduler_handle,
|
||||||
device_hub: Arc::new(PlayerDeviceHub::default()),
|
device_hub: PlayerDeviceHub::shared(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7618,6 +8231,33 @@ impl App for PlayerApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.put({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathId>,
|
||||||
|
json: Json<ReorderPlaylistRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
reorder_playlist_tracks_handler(
|
||||||
|
auth_ctx, session, db, pg_pool, path, json,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.delete({
|
.delete({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
@@ -7957,6 +8597,70 @@ impl App for PlayerApp {
|
|||||||
}),
|
}),
|
||||||
"player_devices_command",
|
"player_devices_command",
|
||||||
),
|
),
|
||||||
|
// -- Federated TUI clients --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/fed-devices",
|
||||||
|
get(
|
||||||
|
move |auth_ctx: auth::AuthContext, session: Session, db: Database| async move {
|
||||||
|
fed_devices_status_handler(auth_ctx, session, db).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"player_fed_devices",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/fed-devices/invite",
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext, session: Session, db: Database| async move {
|
||||||
|
fed_devices_invite_handler(auth_ctx, session, db).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"player_fed_devices_invite",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/fed-devices/connect",
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<FedDeviceConnectRequest>| async move {
|
||||||
|
fed_devices_connect_handler(auth_ctx, session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"player_fed_devices_connect",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/fed-devices/pairing",
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<FedDevicePairingAnswerRequest>| async move {
|
||||||
|
fed_devices_pairing_handler(auth_ctx, session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"player_fed_devices_pairing",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/fed-devices/revoke",
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<FedDeviceRevokeRequest>| async move {
|
||||||
|
fed_devices_revoke_handler(auth_ctx, session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"player_fed_devices_revoke",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/fed-devices/sync",
|
||||||
|
post(
|
||||||
|
move |auth_ctx: auth::AuthContext, session: Session, db: Database| async move {
|
||||||
|
fed_devices_sync_handler(auth_ctx, session, db).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"player_fed_devices_sync",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/jams/users",
|
"/jams/users",
|
||||||
get({
|
get({
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ pub(super) struct AddTracksRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct RemoveTrackRequest {
|
pub(super) struct RemoveTrackRequest {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct ReorderPlaylistRequest {
|
||||||
|
pub(super) playlist_track_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlaylistTrackRow {
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
|
|||||||
@@ -2303,6 +2303,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>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 class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||||
</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>
|
<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">
|
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
||||||
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
||||||
@@ -3268,6 +3291,21 @@ function adminV2() {
|
|||||||
return id ? `${id.slice(0, 12)}…` : '-';
|
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) {
|
async loadSettingsProbe(showErrors = true) {
|
||||||
this.settingsProbeLoading = true;
|
this.settingsProbeLoading = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1251,7 +1251,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
|
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
|
||||||
}
|
}
|
||||||
const track = payload.track || queue.tracks[queue.currentIndex];
|
const track = payload.track || queue.tracks[queue.currentIndex];
|
||||||
if (track) this._playLocal(track, payload);
|
if (track?.unavailable || (track && !track.stream_url)) {
|
||||||
|
this._applyRemotePlaybackState({ ...payload, track, paused: true });
|
||||||
|
this._pauseLocal();
|
||||||
|
} else if (track) {
|
||||||
|
this._playLocal(track, payload);
|
||||||
|
}
|
||||||
} else if (command.command === 'pause') {
|
} else if (command.command === 'pause') {
|
||||||
this.pause();
|
this.pause();
|
||||||
} else if (command.command === 'resume') {
|
} else if (command.command === 'resume') {
|
||||||
@@ -1496,12 +1501,18 @@ document.addEventListener('alpine:init', () => {
|
|||||||
jamSelectedUsers: [],
|
jamSelectedUsers: [],
|
||||||
jamSearching: false,
|
jamSearching: false,
|
||||||
jamLocalPlayback: false,
|
jamLocalPlayback: false,
|
||||||
|
fed: null,
|
||||||
|
fedInvite: '',
|
||||||
|
fedInviteInput: '',
|
||||||
|
fedBusy: false,
|
||||||
|
fedError: '',
|
||||||
remoteHintVisible: false,
|
remoteHintVisible: false,
|
||||||
remoteHintDeviceName: '',
|
remoteHintDeviceName: '',
|
||||||
_remoteHintShown: false,
|
_remoteHintShown: false,
|
||||||
_remoteHintTimer: null,
|
_remoteHintTimer: null,
|
||||||
_pollTimer: null,
|
_pollTimer: null,
|
||||||
_jamSearchTimer: null,
|
_jamSearchTimer: null,
|
||||||
|
_fedRefreshTick: 0,
|
||||||
_stateRefreshTick: 0,
|
_stateRefreshTick: 0,
|
||||||
_lastPlaybackState: null,
|
_lastPlaybackState: null,
|
||||||
|
|
||||||
@@ -1509,9 +1520,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.id = this._ensureId();
|
this.id = this._ensureId();
|
||||||
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
|
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
|
||||||
this.heartbeat();
|
this.heartbeat();
|
||||||
|
this.loadFedDevices();
|
||||||
this._pollTimer = setInterval(() => this.poll(), 500);
|
this._pollTimer = setInterval(() => this.poll(), 500);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) this.poll();
|
if (!document.hidden) {
|
||||||
|
this.poll();
|
||||||
|
this.loadFedDevices();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1561,6 +1576,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
this._apply(data);
|
this._apply(data);
|
||||||
|
if (this.open || (++this._fedRefreshTick % 10 === 0)) {
|
||||||
|
this.loadFedDevices();
|
||||||
|
}
|
||||||
|
|
||||||
const player = Alpine.store('player');
|
const player = Alpine.store('player');
|
||||||
if (player && Array.isArray(data.commands)) {
|
if (player && Array.isArray(data.commands)) {
|
||||||
@@ -1730,7 +1748,142 @@ document.addEventListener('alpine:init', () => {
|
|||||||
toggle() {
|
toggle() {
|
||||||
this.dismissRemoteHint();
|
this.dismissRemoteHint();
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
if (this.open) this.poll();
|
if (this.open) {
|
||||||
|
this.poll();
|
||||||
|
this.loadFedDevices();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fedDevices() {
|
||||||
|
return (this.fed?.devices || []).filter(device => !device.revoked);
|
||||||
|
},
|
||||||
|
|
||||||
|
fedPending() {
|
||||||
|
return this.fed?.pending || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
fedSummary() {
|
||||||
|
if (!this.fed) return 'Fed sync unavailable';
|
||||||
|
const outbox = Number(this.fed.outbox_ops || 0);
|
||||||
|
const unresolved = Number(this.fed.unresolved_items || 0);
|
||||||
|
return `${this.fed.active_devices || 0} linked · ${outbox} queued · ${unresolved} unresolved`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadFedDevices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/fed-devices');
|
||||||
|
if (!res.ok) {
|
||||||
|
this.fedError = await this._errorText(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.fed = await res.json();
|
||||||
|
this.fedError = '';
|
||||||
|
} catch (err) {
|
||||||
|
this.fedError = String(err?.message || err || 'Fed sync unavailable');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateFedInvite() {
|
||||||
|
if (this.fedBusy) return;
|
||||||
|
this.fedBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/fed-devices/invite', { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(await this._errorText(res));
|
||||||
|
const data = await res.json();
|
||||||
|
this.fedInvite = data.invite || '';
|
||||||
|
if (this.fedInvite && navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(this.fedInvite).catch(() => {});
|
||||||
|
}
|
||||||
|
await this.loadFedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
this.fedError = String(err?.message || err || 'Invite failed');
|
||||||
|
} finally {
|
||||||
|
this.fedBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async connectFedInvite() {
|
||||||
|
const invite = this.fedInviteInput.trim();
|
||||||
|
if (!invite || this.fedBusy) return;
|
||||||
|
this.fedBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/fed-devices/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ invite }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await this._errorText(res));
|
||||||
|
this.fedInviteInput = '';
|
||||||
|
await this.loadFedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
this.fedError = String(err?.message || err || 'Connect failed');
|
||||||
|
} finally {
|
||||||
|
this.fedBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async answerFedPairing(request, accept, useRequesterGroup = false) {
|
||||||
|
if (!request?.request_id || this.fedBusy) return;
|
||||||
|
this.fedBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/fed-devices/pairing', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
request_id: request.request_id,
|
||||||
|
accept,
|
||||||
|
use_requester_group: useRequesterGroup,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await this._errorText(res));
|
||||||
|
await this.loadFedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
this.fedError = String(err?.message || err || 'Pairing failed');
|
||||||
|
} finally {
|
||||||
|
this.fedBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeFedDevice(device) {
|
||||||
|
if (!device?.device_id || device.is_self || this.fedBusy) return;
|
||||||
|
if (!window.confirm(`Revoke ${device.name || device.device_id}?`)) return;
|
||||||
|
this.fedBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/fed-devices/revoke', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device_id: device.device_id }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await this._errorText(res));
|
||||||
|
await this.loadFedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
this.fedError = String(err?.message || err || 'Revoke failed');
|
||||||
|
} finally {
|
||||||
|
this.fedBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncFedDevices() {
|
||||||
|
if (this.fedBusy) return;
|
||||||
|
this.fedBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/fed-devices/sync', { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(await this._errorText(res));
|
||||||
|
await this.loadFedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
this.fedError = String(err?.message || err || 'Sync failed');
|
||||||
|
} finally {
|
||||||
|
this.fedBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _errorText(res) {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data.error || res.statusText || 'Request failed';
|
||||||
|
} catch {
|
||||||
|
return res.statusText || 'Request failed';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async select(deviceId) {
|
async select(deviceId) {
|
||||||
@@ -2745,6 +2898,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
async openPlaylist(id, options = {}) {
|
async openPlaylist(id, options = {}) {
|
||||||
this._beginNavigation('#playlist/' + id, options);
|
this._beginNavigation('#playlist/' + id, options);
|
||||||
|
Alpine.store('playlists')?.stopEdit?.();
|
||||||
this.view = 'playlist_detail';
|
this.view = 'playlist_detail';
|
||||||
this.currentPlaylist = null;
|
this.currentPlaylist = null;
|
||||||
try {
|
try {
|
||||||
@@ -2756,6 +2910,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showSharedPlaylist(share, options = {}) {
|
showSharedPlaylist(share, options = {}) {
|
||||||
|
Alpine.store('playlists')?.stopEdit?.();
|
||||||
this._saveScrollPosition(this._activeHash);
|
this._saveScrollPosition(this._activeHash);
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
@@ -4301,6 +4456,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
list: [],
|
list: [],
|
||||||
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
||||||
picker: null, // { trackIds: [1,2,3] }
|
picker: null, // { trackIds: [1,2,3] }
|
||||||
|
editingPlaylistId: null,
|
||||||
|
_dragIdx: null,
|
||||||
|
_dragOverIdx: null,
|
||||||
|
_pointerDragMove: null,
|
||||||
|
_pointerDragEnd: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.reload();
|
this.reload();
|
||||||
@@ -4333,6 +4493,214 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
currentPlaylist() {
|
||||||
|
return Alpine.store('library')?.currentPlaylist || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
canEditCurrent() {
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
return !!playlist && playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
isEditingCurrent() {
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
return this.canEditCurrent() && Number(this.editingPlaylistId) === Number(playlist.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleEditCurrent() {
|
||||||
|
if (this.isEditingCurrent()) {
|
||||||
|
this.stopEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.canEditCurrent()) return;
|
||||||
|
this.editingPlaylistId = Number(this.currentPlaylist().id);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopEdit() {
|
||||||
|
this._endPointerReorder(false);
|
||||||
|
this.endDrag();
|
||||||
|
this.editingPlaylistId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateListTrackCount(playlistId, count) {
|
||||||
|
const id = Number(playlistId);
|
||||||
|
this.list = this.list.map(pl => (
|
||||||
|
Number(pl.id) === id ? { ...pl, track_count: count } : pl
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
|
_playlistTrackIds(tracks) {
|
||||||
|
return (tracks || []).map(track => Number(track?.playlist_track_id || 0)).filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeCurrentTrack(track, idx) {
|
||||||
|
if (!this.isEditingCurrent() || !track) return;
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
const playlistId = Number(playlist.id);
|
||||||
|
const playlistTrackId = Number(track.playlist_track_id || 0);
|
||||||
|
const previous = (playlist.tracks || []).slice();
|
||||||
|
if (!playlistTrackId) return;
|
||||||
|
|
||||||
|
playlist.tracks = previous.filter(item => Number(item.playlist_track_id || 0) !== playlistTrackId);
|
||||||
|
this._updateListTrackCount(playlistId, playlist.tracks.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlist_track_id: playlistTrackId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('remove failed');
|
||||||
|
await this.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||||
|
this.currentPlaylist().tracks = previous;
|
||||||
|
this._updateListTrackCount(playlistId, previous.length);
|
||||||
|
}
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startDrag(event, idx) {
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._dragIdx = idx;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
if (event?.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(tracks[idx].playlist_track_id));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
dragOver(event, idx) {
|
||||||
|
if (!this.isEditingCurrent() || this._dragIdx === null) return;
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (idx < 0 || idx >= tracks.length) return;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
if (event?.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
if (idx !== this._dragIdx) event.currentTarget.classList.add('drag-over');
|
||||||
|
},
|
||||||
|
|
||||||
|
dropOn(idx) {
|
||||||
|
const fromIdx = this._dragIdx;
|
||||||
|
this.endDrag();
|
||||||
|
if (!Number.isInteger(fromIdx) || fromIdx === idx) return;
|
||||||
|
this.moveCurrentTrack(fromIdx, idx);
|
||||||
|
},
|
||||||
|
|
||||||
|
endDrag() {
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
this._dragIdx = null;
|
||||||
|
this._dragOverIdx = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveCurrentTrack(fromIdx, toIdx) {
|
||||||
|
if (!this.isEditingCurrent()) return;
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
const playlistId = Number(playlist.id);
|
||||||
|
const previous = (playlist.tracks || []).slice();
|
||||||
|
if (fromIdx < 0 || fromIdx >= previous.length || toIdx < 0 || toIdx >= previous.length) return;
|
||||||
|
const next = previous.slice();
|
||||||
|
const [track] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(toIdx, 0, track);
|
||||||
|
playlist.tracks = next;
|
||||||
|
|
||||||
|
const playlistTrackIds = this._playlistTrackIds(next);
|
||||||
|
if (playlistTrackIds.length !== next.length) {
|
||||||
|
playlist.tracks = previous;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlist_track_ids: playlistTrackIds }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('reorder failed');
|
||||||
|
await this.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||||
|
this.currentPlaylist().tracks = previous;
|
||||||
|
}
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPointerReorder(event, idx) {
|
||||||
|
if (event.pointerType === 'mouse') return;
|
||||||
|
if (event.button && event.button !== 0) return;
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._endPointerReorder(false);
|
||||||
|
this._dragIdx = idx;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
const handle = event.currentTarget;
|
||||||
|
try {
|
||||||
|
handle?.setPointerCapture?.(event.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
this._pointerDragMove = (moveEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
this._autoScrollDuringReorder(moveEvent.clientY);
|
||||||
|
const target = document
|
||||||
|
.elementFromPoint(moveEvent.clientX, moveEvent.clientY)
|
||||||
|
?.closest?.('.playlist-track-row[data-playlist-index]');
|
||||||
|
const targetIdx = Number(target?.dataset?.playlistIndex);
|
||||||
|
const currentTracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= currentTracks.length) return;
|
||||||
|
this._dragOverIdx = targetIdx;
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
if (targetIdx !== this._dragIdx) target.classList.add('drag-over');
|
||||||
|
};
|
||||||
|
|
||||||
|
this._pointerDragEnd = (endEvent) => {
|
||||||
|
try {
|
||||||
|
if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
this._endPointerReorder(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', this._pointerDragMove, { passive: false });
|
||||||
|
window.addEventListener('pointerup', this._pointerDragEnd, { passive: false });
|
||||||
|
window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
_autoScrollDuringReorder(clientY) {
|
||||||
|
const scroller = document.getElementById('center-scroll');
|
||||||
|
if (!scroller) return;
|
||||||
|
const rect = scroller.getBoundingClientRect();
|
||||||
|
const edge = 52;
|
||||||
|
if (clientY < rect.top + edge) {
|
||||||
|
scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4);
|
||||||
|
} else if (clientY > rect.bottom - edge) {
|
||||||
|
scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_endPointerReorder(commit) {
|
||||||
|
if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove);
|
||||||
|
if (this._pointerDragEnd) {
|
||||||
|
window.removeEventListener('pointerup', this._pointerDragEnd);
|
||||||
|
window.removeEventListener('pointercancel', this._pointerDragEnd);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
const fromIdx = this._dragIdx;
|
||||||
|
const toIdx = this._dragOverIdx;
|
||||||
|
this._pointerDragMove = null;
|
||||||
|
this._pointerDragEnd = null;
|
||||||
|
this._dragIdx = null;
|
||||||
|
this._dragOverIdx = null;
|
||||||
|
if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) {
|
||||||
|
this.moveCurrentTrack(fromIdx, toIdx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
showCreate() {
|
showCreate() {
|
||||||
this.modal = { mode: 'create', title: '' };
|
this.modal = { mode: 'create', title: '' };
|
||||||
},
|
},
|
||||||
|
|||||||
+126
-15
@@ -951,7 +951,23 @@
|
|||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
<div class="playlist-detail-heading">
|
||||||
|
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||||
|
<button class="release-action-btn secondary playlist-edit-toggle"
|
||||||
|
x-show="$store.playlists.canEditCurrent()"
|
||||||
|
x-cloak
|
||||||
|
@click="$store.playlists.toggleEditCurrent()"
|
||||||
|
:title="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'">
|
||||||
|
<svg x-show="!$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="playlist-detail-meta"
|
<div class="playlist-detail-meta"
|
||||||
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||||||
<span x-show="$store.library.currentPlaylist.owner_name"
|
<span x-show="$store.library.currentPlaylist.owner_name"
|
||||||
@@ -963,27 +979,61 @@
|
|||||||
<template x-if="$store.library.currentPlaylist.description">
|
<template x-if="$store.library.currentPlaylist.description">
|
||||||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||||||
</template>
|
</template>
|
||||||
<div class="track-list-header">
|
<div class="track-list-header playlist-track-list-header"
|
||||||
|
:class="{ editing: $store.playlists.isEditingCurrent() }">
|
||||||
|
<span class="playlist-edit-cell"
|
||||||
|
x-show="$store.playlists.isEditingCurrent()"
|
||||||
|
x-cloak></span>
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>{{ t.player_title }}</span>
|
<span>{{ t.player_title }}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.playlist_track_id || (track.id + '-' + idx)">
|
||||||
<div class="track-row"
|
<div class="track-row playlist-track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:data-playlist-index="idx"
|
||||||
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, editing: $store.playlists.isEditingCurrent(), dragging: $store.playlists._dragIdx === idx }"
|
||||||
|
:draggable="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||||
|
@dblclick="if (!$store.playlists.isEditingCurrent()) $store.queue.playRelease($store.library.currentPlaylist.tracks, idx)"
|
||||||
|
@dragstart="if (!$store.playlists.startDrag($event, idx)) $event.preventDefault()"
|
||||||
|
@dragend="$store.playlists.endDrag()"
|
||||||
|
@dragover.prevent="$store.playlists.dragOver($event, idx)"
|
||||||
|
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||||
|
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); $store.playlists.dropOn(idx)">
|
||||||
|
<button class="playlist-track-remove"
|
||||||
|
x-show="$store.playlists.isEditingCurrent()"
|
||||||
|
x-cloak
|
||||||
|
@click.stop="$store.playlists.removeCurrentTrack(track, idx)"
|
||||||
|
title="{{ t.player_remove }}"
|
||||||
|
aria-label="{{ t.player_remove }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info playlist-track-info">
|
||||||
<div class="track-title" x-text="track.title"></div>
|
<button class="playlist-drag-handle"
|
||||||
<div class="track-artists-inline">
|
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
x-cloak
|
||||||
<span>
|
@mousedown.stop
|
||||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
@click.stop
|
||||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
@pointerdown.stop="$store.playlists.startPointerReorder($event, idx)"
|
||||||
</span>
|
title="{{ t.player_edit }}"
|
||||||
</template>
|
aria-label="{{ t.player_edit }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="playlist-track-copy">
|
||||||
|
<div class="track-title" x-text="track.title"></div>
|
||||||
|
<div class="track-artists-inline">
|
||||||
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
|
<span>
|
||||||
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||||
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -1285,6 +1335,67 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
||||||
|
</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 x-if="$store.devices.jams.length > 0">
|
<template x-if="$store.devices.jams.length > 0">
|
||||||
<div class="device-section-label jam-section-label">Jams</div>
|
<div class="device-section-label jam-section-label">Jams</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -489,6 +489,25 @@ button.user-stat:hover {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading .section-title {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -735,6 +754,93 @@ button.user-stat:hover {
|
|||||||
.track-row:hover { background: var(--bg-hover); }
|
.track-row:hover { background: var(--bg-hover); }
|
||||||
.track-row.playing { color: var(--accent); }
|
.track-row.playing { color: var(--accent); }
|
||||||
.track-row.playing .track-num { color: var(--accent); }
|
.track-row.playing .track-num { color: var(--accent); }
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 32px 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-copy {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle {
|
||||||
|
width: 24px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row.shared-target {
|
.track-row.shared-target {
|
||||||
background: rgba(29, 185, 84, 0.12);
|
background: rgba(29, 185, 84, 0.12);
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
@@ -1750,9 +1856,9 @@ button.user-stat:hover {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 38px;
|
bottom: 38px;
|
||||||
width: 260px;
|
width: 320px;
|
||||||
max-width: calc(100vw - 24px);
|
max-width: calc(100vw - 24px);
|
||||||
max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px));
|
max-height: min(440px, calc(100dvh - var(--player-bar-space) - 24px));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1849,6 +1955,142 @@ button.user-stat:hover {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fed-section-label {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #b8d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-panel {
|
||||||
|
margin: 2px 2px 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(82,145,255,0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(82,145,255,0.045);
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-status,
|
||||||
|
.fed-device-error,
|
||||||
|
.fed-pairing-note {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-error {
|
||||||
|
color: #ffb2b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-row {
|
||||||
|
min-height: 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #73d795;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-dot.self {
|
||||||
|
background: #ffd166;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-name,
|
||||||
|
.fed-pairing-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-meta,
|
||||||
|
.fed-pairing-meta {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-pairing-card {
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-actions,
|
||||||
|
.fed-connect-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-connect-row .fed-device-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn,
|
||||||
|
.fed-revoke-btn {
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn:hover,
|
||||||
|
.fed-revoke-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.13);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn.primary {
|
||||||
|
background: rgba(82,145,255,0.16);
|
||||||
|
color: #c9dcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-revoke-btn {
|
||||||
|
background: rgba(255,96,96,0.1);
|
||||||
|
color: #ffb2b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(82,145,255,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.jam-section-label,
|
.jam-section-label,
|
||||||
.jam-row,
|
.jam-row,
|
||||||
.start-jam-row,
|
.start-jam-row,
|
||||||
@@ -4233,6 +4475,16 @@ button.user-stat:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px 32px minmax(0, 1fr) auto 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing span:nth-child(4),
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.history-table-head,
|
.history-table-head,
|
||||||
.history-row.track-row {
|
.history-row.track-row {
|
||||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
@@ -5158,6 +5410,26 @@ button.user-stat:hover {
|
|||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing .track-num,
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row > span:nth-child(3),
|
.track-row > span:nth-child(3),
|
||||||
.track-duration {
|
.track-duration {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user