This commit is contained in:
Generated
+5
-4
@@ -617,9 +617,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -1718,7 +1718,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "federation-net"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -3622,7 +3622,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
@@ -3633,6 +3633,7 @@ dependencies = [
|
||||
"rand 0.9.5",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+135
-2
@@ -12,6 +12,7 @@
|
||||
//! `federation_network_id`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
|
||||
pub mod devices;
|
||||
mod serve;
|
||||
mod storage;
|
||||
|
||||
@@ -196,6 +197,7 @@ impl Federation {
|
||||
.rendezvous(RendezvousConfig::default())
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.stream_protocol(devices::SYNC_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) =
|
||||
@@ -240,15 +242,34 @@ impl Federation {
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
||||
let catalog_task = tokio::spawn(serve::serve_catalog(
|
||||
catalog_acceptor,
|
||||
pool,
|
||||
pool.clone(),
|
||||
storage_dir,
|
||||
service.endpoint_id(),
|
||||
));
|
||||
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),
|
||||
));
|
||||
let device_sync_task =
|
||||
tokio::spawn(devices::sync_loop(pool, Arc::clone(&service), device_hub));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
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);
|
||||
drop(guard);
|
||||
@@ -607,6 +628,118 @@ impl Federation {
|
||||
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||||
Ok(peer.to_string())
|
||||
}
|
||||
|
||||
pub async fn fed_device_status(
|
||||
&self,
|
||||
user_id: i64,
|
||||
user_name: &str,
|
||||
) -> Result<devices::FedDeviceStatus> {
|
||||
let pool = self.pool().await?;
|
||||
devices::status(&pool, user_id, user_name).await
|
||||
}
|
||||
|
||||
pub async fn fed_device_invite(&self, user_id: i64, user_name: &str) -> Result<String> {
|
||||
let service = self.service().await?;
|
||||
let pool = self.pool().await?;
|
||||
devices::create_invite(&pool, service, user_id, user_name).await
|
||||
}
|
||||
|
||||
pub async fn fed_device_connect(
|
||||
&self,
|
||||
user_id: i64,
|
||||
user_name: &str,
|
||||
invite: &str,
|
||||
) -> Result<String> {
|
||||
let network_id = devices::invite_network_id(invite)?;
|
||||
{
|
||||
let guard = self.running.lock().await;
|
||||
let Some(running) = guard.as_ref() else {
|
||||
anyhow::bail!("federation is not running");
|
||||
};
|
||||
let expected = NetworkId::from_name(&running.network_name);
|
||||
anyhow::ensure!(
|
||||
network_id == expected,
|
||||
"device invite belongs to a different federation network"
|
||||
);
|
||||
}
|
||||
let service = self.service().await?;
|
||||
let pool = self.pool().await?;
|
||||
devices::connect_invite(
|
||||
&pool,
|
||||
service,
|
||||
crate::player::PlayerDeviceHub::shared(),
|
||||
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(),
|
||||
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(
|
||||
|
||||
@@ -1951,6 +1951,278 @@ pub mod db_migrations {
|
||||
&[Operation::custom(create_playlist_share_links).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||
media_file_id BIGINT PRIMARY KEY,
|
||||
sha256_hash TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||
ON furumusic__federation_content_id_cache (content_id)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL UNIQUE,
|
||||
group_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
local_seq BIGINT NOT NULL DEFAULT 0,
|
||||
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
|
||||
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_sync TEXT,
|
||||
last_error TEXT
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
|
||||
user_id BIGINT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
client_version TEXT NOT NULL DEFAULT '',
|
||||
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||
endpoint_id TEXT NOT NULL DEFAULT '',
|
||||
endpoint_ticket TEXT NOT NULL DEFAULT '',
|
||||
trusted_at_ms BIGINT,
|
||||
last_seen_ms BIGINT,
|
||||
revoked_at_ms BIGINT,
|
||||
revoked_by TEXT,
|
||||
revoke_cutoff_seq BIGINT,
|
||||
PRIMARY KEY (user_id, device_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
|
||||
ON furumusic__fed_device (device_id)
|
||||
WHERE trusted_at_ms IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
|
||||
invite_id TEXT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
secret_hash TEXT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
used_at_ms BIGINT
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
client_version TEXT NOT NULL,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
endpoint_ticket TEXT NOT NULL,
|
||||
invite_id TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
answered_at_ms BIGINT,
|
||||
status TEXT NOT NULL,
|
||||
requester_group_id TEXT,
|
||||
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
|
||||
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
|
||||
use_requester_group BOOLEAN NOT NULL DEFAULT false
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
|
||||
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
|
||||
user_id BIGINT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
origin_device_id TEXT NOT NULL,
|
||||
seq BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
hlc_ms BIGINT NOT NULL,
|
||||
received_at_ms BIGINT NOT NULL,
|
||||
tombstone BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (user_id, op_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
|
||||
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
|
||||
ON furumusic__fed_sync_ops (user_id, tombstone)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
|
||||
user_id BIGINT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
max_seq BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
|
||||
user_id BIGINT NOT NULL,
|
||||
peer_device_id TEXT NOT NULL,
|
||||
origin_device_id TEXT NOT NULL,
|
||||
max_seq BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
|
||||
user_id BIGINT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
liked BOOLEAN NOT NULL,
|
||||
hlc_ms BIGINT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
local_track_id BIGINT,
|
||||
fed_json JSONB,
|
||||
PRIMARY KEY (user_id, content_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
|
||||
user_id BIGINT NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
local_playlist_id BIGINT,
|
||||
title TEXT NOT NULL,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
hlc_ms BIGINT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, playlist_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
|
||||
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
|
||||
WHERE local_playlist_id IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
|
||||
user_id BIGINT NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
present BOOLEAN NOT NULL DEFAULT true,
|
||||
position BIGINT NOT NULL DEFAULT 0,
|
||||
hlc_ms BIGINT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
local_track_id BIGINT,
|
||||
fed_json JSONB,
|
||||
PRIMARY KEY (user_id, playlist_id, content_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
|
||||
ON furumusic__fed_state_playlist_item
|
||||
(user_id, playlist_id, present, position)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
|
||||
user_id BIGINT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
applied_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, op_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0038CreateFedDeviceSync;
|
||||
|
||||
impl migrations::Migration for M0038CreateFedDeviceSync {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0037_create_playlist_share_links",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_fed_device_sync).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn ensure_federation_content_id_cache(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||
media_file_id BIGINT PRIMARY KEY,
|
||||
sha256_hash TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||
ON furumusic__federation_content_id_cache (content_id)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0039EnsureFederationContentIdCache;
|
||||
|
||||
impl migrations::Migration for M0039EnsureFederationContentIdCache {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0038_create_fed_device_sync",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(ensure_federation_content_id_cache).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -1979,5 +2251,7 @@ pub mod db_migrations {
|
||||
&M0035CreateEntityGenreTags,
|
||||
&M0036CreateExternalMetadataIds,
|
||||
&M0037CreatePlaylistShareLinks,
|
||||
&M0038CreateFedDeviceSync,
|
||||
&M0039EnsureFederationContentIdCache,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -272,6 +272,24 @@ pub(super) struct PlayerDevicesResponse {
|
||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FedDeviceConnectRequest {
|
||||
pub(super) invite: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FedDevicePairingAnswerRequest {
|
||||
pub(super) request_id: String,
|
||||
pub(super) accept: bool,
|
||||
#[serde(default)]
|
||||
pub(super) use_requester_group: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FedDeviceRevokeRequest {
|
||||
pub(super) device_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayerDevicePollResponse {
|
||||
pub(super) device_id: String,
|
||||
|
||||
+527
-11
@@ -1,5 +1,5 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use cot::db::Database;
|
||||
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_CANDIDATE_LIMIT: i64 = 220;
|
||||
const PLAYER_RADIO_RELEASE_SEED_TRACKS: i64 = 4;
|
||||
const FED_DEVICE_PREFIX: &str = "fed:";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayerDevice {
|
||||
@@ -108,11 +109,136 @@ struct PlayerDeviceHubState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PlayerDeviceHub {
|
||||
pub(crate) struct PlayerDeviceHub {
|
||||
state: Mutex<PlayerDeviceHubState>,
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
user_id: i64,
|
||||
@@ -194,7 +320,9 @@ impl PlayerDeviceHub {
|
||||
state
|
||||
.playback_state_by_user
|
||||
.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(
|
||||
&mut state,
|
||||
user_id,
|
||||
@@ -772,6 +900,18 @@ fn current_millis() -> i64 {
|
||||
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(
|
||||
mut playback_state: PlayerDevicePlaybackStateDto,
|
||||
now: i64,
|
||||
@@ -796,7 +936,7 @@ fn normalize_device_id(raw: &str) -> Option<String> {
|
||||
}
|
||||
if !trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ':')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -949,6 +1089,14 @@ mod device_tests {
|
||||
);
|
||||
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)]
|
||||
@@ -4533,6 +4681,7 @@ async fn devices_select_handler(
|
||||
.as_deref()
|
||||
.and_then(normalize_device_id)
|
||||
.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 {
|
||||
return Ok(json_error(
|
||||
@@ -4540,6 +4689,28 @@ async fn devices_select_handler(
|
||||
"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()
|
||||
}
|
||||
|
||||
@@ -4584,6 +4755,33 @@ async fn devices_command_handler(
|
||||
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(
|
||||
user.id,
|
||||
target_device_id.as_deref(),
|
||||
@@ -4596,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) {
|
||||
let Some(tracks) = payload
|
||||
.get_mut("tracks")
|
||||
@@ -4618,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(
|
||||
auth_ctx: auth::AuthContext,
|
||||
session: Session,
|
||||
@@ -5460,6 +5865,8 @@ async fn create_playlist_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
record_fed_playlist_created(pool, user.id, row.0, &title).await;
|
||||
|
||||
Json(PlaylistCard {
|
||||
id: row.0,
|
||||
title,
|
||||
@@ -5513,6 +5920,7 @@ async fn update_playlist_handler(
|
||||
.execute(pool)
|
||||
.await
|
||||
.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 {
|
||||
@@ -5556,6 +5964,7 @@ async fn delete_playlist_handler(
|
||||
if owner.0 != user.id {
|
||||
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")
|
||||
.bind(playlist_id)
|
||||
.execute(pool)
|
||||
@@ -5638,6 +6047,8 @@ async fn add_tracks_to_playlist_handler(
|
||||
.await
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -5742,6 +6153,19 @@ async fn reorder_playlist_tracks_handler(
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -5773,6 +6197,23 @@ async fn remove_track_from_playlist_handler(
|
||||
if owner.0 != user.id {
|
||||
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()
|
||||
}
|
||||
};
|
||||
|
||||
match (body.playlist_track_id, body.track_id) {
|
||||
(Some(playlist_track_id), _) => {
|
||||
@@ -5821,6 +6262,8 @@ async fn remove_track_from_playlist_handler(
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -5855,6 +6298,7 @@ async fn toggle_like_track_handler(
|
||||
.execute(pool)
|
||||
.await
|
||||
.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()
|
||||
} else {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
@@ -5867,6 +6311,7 @@ async fn toggle_like_track_handler(
|
||||
.execute(pool)
|
||||
.await
|
||||
.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()
|
||||
}
|
||||
}
|
||||
@@ -5887,16 +6332,17 @@ async fn like_release_handler(
|
||||
};
|
||||
let release_id = path.0.id;
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
// Check if ALL tracks in this release are already liked
|
||||
let total: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
|
||||
let track_ids = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT id FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_one(pool)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.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(
|
||||
r#"SELECT COUNT(*) FROM furumusic__user_liked_track ult
|
||||
JOIN furumusic__track t ON t.id = ult.track_id
|
||||
@@ -5908,7 +6354,7 @@ async fn like_release_handler(
|
||||
.await
|
||||
.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
|
||||
sqlx::query(
|
||||
r#"DELETE FROM furumusic__user_liked_track
|
||||
@@ -5921,6 +6367,9 @@ async fn like_release_handler(
|
||||
.execute(pool)
|
||||
.await
|
||||
.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()
|
||||
} else {
|
||||
// Like all tracks in release (skip already liked)
|
||||
@@ -5940,6 +6389,9 @@ async fn like_release_handler(
|
||||
.execute(pool)
|
||||
.await
|
||||
.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()
|
||||
}
|
||||
}
|
||||
@@ -6600,7 +7052,7 @@ impl PlayerApp {
|
||||
Self {
|
||||
config,
|
||||
scheduler_handle,
|
||||
device_hub: Arc::new(PlayerDeviceHub::default()),
|
||||
device_hub: PlayerDeviceHub::shared(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8145,6 +8597,70 @@ impl App for PlayerApp {
|
||||
}),
|
||||
"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(
|
||||
"/jams/users",
|
||||
get({
|
||||
|
||||
@@ -1251,7 +1251,12 @@ document.addEventListener('alpine:init', () => {
|
||||
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
|
||||
}
|
||||
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') {
|
||||
this.pause();
|
||||
} else if (command.command === 'resume') {
|
||||
@@ -1496,12 +1501,18 @@ document.addEventListener('alpine:init', () => {
|
||||
jamSelectedUsers: [],
|
||||
jamSearching: false,
|
||||
jamLocalPlayback: false,
|
||||
fed: null,
|
||||
fedInvite: '',
|
||||
fedInviteInput: '',
|
||||
fedBusy: false,
|
||||
fedError: '',
|
||||
remoteHintVisible: false,
|
||||
remoteHintDeviceName: '',
|
||||
_remoteHintShown: false,
|
||||
_remoteHintTimer: null,
|
||||
_pollTimer: null,
|
||||
_jamSearchTimer: null,
|
||||
_fedRefreshTick: 0,
|
||||
_stateRefreshTick: 0,
|
||||
_lastPlaybackState: null,
|
||||
|
||||
@@ -1509,9 +1520,13 @@ document.addEventListener('alpine:init', () => {
|
||||
this.id = this._ensureId();
|
||||
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
|
||||
this.heartbeat();
|
||||
this.loadFedDevices();
|
||||
this._pollTimer = setInterval(() => this.poll(), 500);
|
||||
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();
|
||||
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||
this._apply(data);
|
||||
if (this.open || (++this._fedRefreshTick % 10 === 0)) {
|
||||
this.loadFedDevices();
|
||||
}
|
||||
|
||||
const player = Alpine.store('player');
|
||||
if (player && Array.isArray(data.commands)) {
|
||||
@@ -1730,7 +1748,142 @@ document.addEventListener('alpine:init', () => {
|
||||
toggle() {
|
||||
this.dismissRemoteHint();
|
||||
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) {
|
||||
|
||||
@@ -1335,6 +1335,67 @@
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="device-section-label fed-section-label">Fed Clients</div>
|
||||
<div class="fed-device-panel">
|
||||
<div class="fed-device-status" x-text="$store.devices.fedSummary()"></div>
|
||||
<template x-if="$store.devices.fedError">
|
||||
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||
</template>
|
||||
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
|
||||
<div class="fed-pairing-card">
|
||||
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
|
||||
<div class="fed-pairing-meta"
|
||||
x-text="request.requester_group_id ? 'Already in another sync group' : (request.client_version || 'waiting for approval')"></div>
|
||||
<template x-if="request.requester_group_id">
|
||||
<div class="fed-pairing-note">Recommended keeps the existing group intact.</div>
|
||||
</template>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
|
||||
x-text="request.requester_group_id ? 'Recommended' : 'Approve'"></button>
|
||||
<button class="fed-action-btn"
|
||||
@click="$store.devices.answerFedPairing(request, false, false)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="device in $store.devices.fedDevices()" :key="device.device_id">
|
||||
<div class="fed-device-row" :class="{ self: device.is_self }">
|
||||
<span class="fed-device-dot" :class="{ self: device.is_self }"></span>
|
||||
<span class="fed-device-main">
|
||||
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
|
||||
<span class="fed-device-meta"
|
||||
x-text="(device.is_self ? 'WEB' : (device.client_version || 'unknown'))"></span>
|
||||
</span>
|
||||
<button class="fed-revoke-btn"
|
||||
x-show="!device.is_self"
|
||||
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.generateFedInvite()">Invite</button>
|
||||
<button class="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.syncFedDevices()">Sync</button>
|
||||
</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">
|
||||
<div class="device-section-label jam-section-label">Jams</div>
|
||||
</template>
|
||||
|
||||
@@ -1856,9 +1856,9 @@ button.user-stat:hover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 38px;
|
||||
width: 260px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px));
|
||||
max-height: min(440px, calc(100dvh - var(--player-bar-space) - 24px));
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1955,6 +1955,142 @@ button.user-stat:hover {
|
||||
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-row,
|
||||
.start-jam-row,
|
||||
|
||||
Reference in New Issue
Block a user