Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m10s

This commit is contained in:
Ultradesu
2026-07-24 16:45:11 +03:00
parent d1370c6a28
commit c2bdd62a51
10 changed files with 5543 additions and 23 deletions
File diff suppressed because it is too large Load Diff
+135 -2
View File
@@ -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(
+274
View File
@@ -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,
];
}
+18
View File
@@ -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
View File
@@ -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, &current_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({