use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex, OnceLock}; use bytes::Bytes; use cot::db::Database; use cot::http::StatusCode; use cot::http::header::{ ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HeaderName, RANGE, }; use cot::json::Json; use cot::request::extractors::{Path, UrlQuery}; use cot::response::IntoResponse; use cot::router::method::{delete, get, post}; use cot::router::{Route, Router}; use cot::session::Session; use cot::{App, Body, Template}; use serde::{Deserialize, Serialize}; use sqlx::Row as _; use crate::auth; use crate::config::AppConfig; use crate::i18n::Translations; use crate::lastfm::{LastfmClient, LastfmCredentials, LastfmTrackPayload}; use crate::local_uploads::LocalUploadDto; use crate::scheduler::SchedulerHandle; use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest}; use crate::youtube::{YouTubePreviewRequest, YouTubeService, YouTubeStartRequest}; mod dto; mod helpers; mod queries; mod rows; use dto::*; use helpers::{cover_variant_url, load_release_uploaders, track_cover_variant_url}; use queries::*; use rows::*; // --------------------------------------------------------------------------- // JSON error helper // --------------------------------------------------------------------------- fn json_error(status: StatusCode, message: &str) -> cot::response::Response { let body = serde_json::json!({ "error": message }); cot::http::Response::builder() .status(status) .header(CONTENT_TYPE, "application/json") .body(Body::fixed(body.to_string())) .expect("valid response") } #[derive(Debug, Clone, Copy)] enum DownloadMethod { LocalFile, Torrent, YouTube, } fn require_download_method( config: &AppConfig, method: DownloadMethod, ) -> Result<(), cot::response::Response> { let enabled = config.downloads_enabled && match method { DownloadMethod::LocalFile => true, DownloadMethod::Torrent => config.torrent_downloads_enabled, DownloadMethod::YouTube => config.youtube_downloads_enabled, }; if enabled { Ok(()) } else { Err(json_error( StatusCode::FORBIDDEN, match method { DownloadMethod::LocalFile => "downloads are disabled by the administrator", DownloadMethod::Torrent => "torrent downloads are disabled by the administrator", DownloadMethod::YouTube => "YouTube downloads are disabled by the administrator", }, )) } } fn download_proxy_for( config: &AppConfig, method: DownloadMethod, ) -> Result, cot::response::Response> { require_download_method(config, method)?; let result = match method { DownloadMethod::LocalFile => Ok(None), DownloadMethod::Torrent => config.torrent_proxy_url(), DownloadMethod::YouTube => config.youtube_proxy_url(), }; result.map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string())) } async fn youtube_cookie_contents( config: &AppConfig, db: &Database, ) -> Result, cot::response::Response> { crate::youtube::selected_cookie_contents(db, &config.youtube_cookie_id) .await .map_err(|error| json_error(StatusCode::BAD_REQUEST, &error.to_string())) } #[derive(serde::Serialize)] struct LocalUploadResponse { ok: bool, upload: LocalUploadDto, } const PLAYER_DEVICE_TTL_MS: i64 = 30_000; const PLAYER_DEVICE_RETURN_TAKEOVER_MS: i64 = 30 * 60 * 1_000; const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000; const PLAYER_DEVICE_MAX_COMMANDS: usize = 32; const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000; 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 { id: String, name: String, kind: String, last_seen_ms: i64, } #[derive(Debug, Clone)] struct PendingPlayerDeviceCommand { id: String, command: String, payload: serde_json::Value, created_at_ms: i64, } #[derive(Debug, Clone, PartialEq, Eq)] enum PlayerJamMemberStatus { Invited, Joined, } #[derive(Debug, Clone)] struct PlayerJamMember { name: String, status: PlayerJamMemberStatus, last_seen_ms: i64, } #[derive(Debug, Clone)] struct PlayerJamSession { id: String, host_user_id: i64, host_name: String, host_last_seen_ms: i64, members: HashMap, } #[derive(Debug, Default)] struct PlayerDeviceHubState { devices_by_user: HashMap>, device_last_seen_ms: HashMap<(i64, String), i64>, active_device_by_user: HashMap, commands_by_device: HashMap<(i64, String), VecDeque>, playback_state_by_user: HashMap, jams_by_id: HashMap, } #[derive(Debug, Default)] pub(crate) struct PlayerDeviceHub { state: Mutex, } impl PlayerDeviceHub { pub(crate) fn shared() -> Arc { static HUB: OnceLock> = 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 federation_playback_is_local(&self, user_id: i64) -> bool { let state = self.state.lock().expect("player device hub lock"); !state .active_device_by_user .get(&user_id) .is_some_and(|id| is_fed_virtual_device_id(id)) } pub(crate) fn playback_state_json_for_commands( &self, user_id: i64, ) -> Option { 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 { 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 { 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, }, ); // Match the trusted-device playback contract used by the TUI: a // background/stale active snapshot must not steal playback from a // browser that is actively playing. An explicit web handoff changes // `active_device_by_user` to the federated virtual device before the // snapshot arrives, so it still passes through here. let local_playback_is_protected = state .active_device_by_user .get(&user_id) .is_some_and(|active_id| !is_fed_virtual_device_id(active_id)) && state .playback_state_by_user .get(&user_id) .is_some_and(|playback| playback.track.is_some() && !playback.paused); if active && local_playback_is_protected { return Ok(()); } 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, device_id: &str, user_agent: Option<&str>, current_jam_id: Option<&str>, playback_state: Option, ) -> (PlayerDevicesResponse, Option) { let now = current_millis(); let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); let is_new_or_returning = state .device_last_seen_ms .get(&(user_id, device_id.to_string())) .is_none_or(|last_seen| { now.saturating_sub(*last_seen) >= PLAYER_DEVICE_RETURN_TAKEOVER_MS }); let previous_active_id = state.active_device_by_user.get(&user_id).cloned(); self.touch_locked(&mut state, user_id, device_id, user_agent, now); let active_is_playing = state .playback_state_by_user .get(&user_id) .is_some_and(|playback| playback.track.is_some() && !playback.paused); let should_claim_idle_playback = is_new_or_returning && previous_active_id.as_deref() != Some(device_id) && !active_is_playing; if should_claim_idle_playback { let transfer_state = state .playback_state_by_user .get(&user_id) .cloned() .map(|playback| playback_state_at(playback, now)); state .active_device_by_user .insert(user_id, device_id.to_string()); if let Some(transfer_state) = transfer_state { state .playback_state_by_user .insert(user_id, transfer_state.clone()); if let Ok(payload) = serde_json::to_value(transfer_state) { self.enqueue_command_locked( &mut state, user_id, device_id, "transfer_state", payload, now, ); } } } self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now); ( self.snapshot_locked(&state, user_id, device_id, current_jam_id, now), should_claim_idle_playback .then_some(previous_active_id) .flatten(), ) } fn poll( &self, user_id: i64, device_id: &str, user_agent: Option<&str>, current_jam_id: Option<&str>, playback_state: Option, ) -> PlayerDevicePollResponse { let now = current_millis(); let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); self.touch_locked(&mut state, user_id, device_id, user_agent, now); self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now); let commands = state .commands_by_device .remove(&(user_id, device_id.to_string())) .unwrap_or_default() .into_iter() .map(|cmd| PlayerDeviceCommandDto { id: cmd.id, command: cmd.command, payload: cmd.payload, }) .collect(); let snapshot = self.snapshot_locked(&state, user_id, device_id, current_jam_id, now); PlayerDevicePollResponse { device_id: snapshot.device_id, active_device_id: snapshot.active_device_id, devices: snapshot.devices, jams: snapshot.jams, current_jam_id: snapshot.current_jam_id, commands, playback_state: snapshot.playback_state, } } fn select( &self, user_id: i64, current_device_id: &str, target_device_id: &str, ) -> Option { 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)?; if !devices.contains_key(target_device_id) { return None; } let previous_active_id = state.active_device_by_user.get(&user_id).cloned(); let transfer_state = state .playback_state_by_user .get(&user_id) .cloned() .map(|playback_state| playback_state_at(playback_state, now)); state .active_device_by_user .insert(user_id, target_device_id.to_string()); if previous_active_id.as_deref() != Some(target_device_id) { if let Some(transfer_state) = transfer_state { state .playback_state_by_user .insert(user_id, transfer_state.clone()); 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, target_device_id, "transfer_state", payload, now, ); } } } Some(self.snapshot_locked(&state, user_id, current_device_id, None, now)) } fn enqueue_command( &self, user_id: i64, target_device_id: Option<&str>, jam_id: Option<&str>, 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 (target_user_id, target_id) = if let Some(jam_id) = jam_id { let jam = state.jams_by_id.get(jam_id).ok_or("jam is not available")?; let member = jam.members.get(&user_id).ok_or("jam is not available")?; if member.status != PlayerJamMemberStatus::Joined { return Err("join the jam first"); } let target_id = self .jam_target_device_id_locked(&state, jam) .ok_or("jam playback device is offline")?; (jam.host_user_id, target_id) } else { let target_id = match target_device_id { Some(id) => id.to_string(), None => state .active_device_by_user .get(&user_id) .cloned() .ok_or("no active device")?, }; let devices = state .devices_by_user .get(&user_id) .ok_or("target device is offline")?; if !devices.contains_key(&target_id) { return Err("target device is offline"); } (user_id, target_id) }; self.enqueue_command_locked( &mut state, target_user_id, &target_id, command, payload, now, ); Ok(()) } fn enqueue_command_locked( &self, state: &mut PlayerDeviceHubState, user_id: i64, target_device_id: &str, command: &str, payload: serde_json::Value, now: i64, ) { let queue = state .commands_by_device .entry((user_id, target_device_id.to_string())) .or_default(); while queue.len() >= PLAYER_DEVICE_MAX_COMMANDS { queue.pop_front(); } queue.push_back(PendingPlayerDeviceCommand { id: uuid::Uuid::new_v4().simple().to_string(), command: command.to_string(), payload, created_at_ms: now, }); } fn touch_locked( &self, state: &mut PlayerDeviceHubState, user_id: i64, device_id: &str, user_agent: Option<&str>, now: i64, ) { let devices = state.devices_by_user.entry(user_id).or_default(); let device = PlayerDevice { id: device_id.to_string(), name: device_name_from_user_agent(user_agent), kind: device_kind_from_user_agent(user_agent).to_string(), last_seen_ms: now, }; devices.insert(device_id.to_string(), device); state .device_last_seen_ms .insert((user_id, device_id.to_string()), now); let active_online = state .active_device_by_user .get(&user_id) .is_some_and(|active_id| devices.contains_key(active_id)); if !active_online { state .active_device_by_user .insert(user_id, device_id.to_string()); } } fn update_playback_state_locked( &self, state: &mut PlayerDeviceHubState, user_id: i64, device_id: &str, playback_state: Option, now: i64, ) { let is_active = state .active_device_by_user .get(&user_id) .is_some_and(|active_id| active_id == device_id); if !is_active { return; } let Some(mut playback_state) = playback_state else { return; }; playback_state.updated_at_ms = now; state.playback_state_by_user.insert(user_id, playback_state); self.touch_host_jams_locked(state, user_id, device_id, now); } fn snapshot_locked( &self, state: &PlayerDeviceHubState, user_id: i64, current_device_id: &str, current_jam_id: Option<&str>, now: i64, ) -> PlayerDevicesResponse { let active_device_id = state.active_device_by_user.get(&user_id).cloned(); let current_jam_id = current_jam_id .filter(|jam_id| self.jam_accessible_locked(state, user_id, jam_id, false)); let mut devices: Vec = state .devices_by_user .get(&user_id) .map(|devices| { devices .values() .map(|device| PlayerDeviceDto { id: device.id.clone(), name: device.name.clone(), kind: device.kind.clone(), is_current: device.id == current_device_id, is_active: active_device_id.as_deref() == Some(device.id.as_str()), last_seen_ms: now.saturating_sub(device.last_seen_ms), }) .collect() }) .unwrap_or_default(); devices.sort_by(|a, b| { b.is_active .cmp(&a.is_active) .then_with(|| b.is_current.cmp(&a.is_current)) .then_with(|| a.name.cmp(&b.name)) }); PlayerDevicesResponse { device_id: current_device_id.to_string(), active_device_id, devices, jams: self.jam_dtos_locked(state, user_id, current_jam_id, now), current_jam_id: current_jam_id.map(str::to_string), playback_state: self.playback_state_for_context_locked( state, user_id, current_jam_id, now, ), } } fn create_jam( &self, host_user_id: i64, host_name: &str, current_device_id: &str, invitees: Vec<(i64, String)>, ) -> Result { let now = current_millis(); let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); if self.user_has_joined_jam_locked(&state, host_user_id) { return Err("leave the current jam before creating a new one"); } let devices = state .devices_by_user .get(&host_user_id) .ok_or("current device is offline")?; if !devices.contains_key(current_device_id) { return Err("current device is offline"); } state .active_device_by_user .insert(host_user_id, current_device_id.to_string()); let mut seen = HashSet::new(); let mut members = HashMap::new(); members.insert( host_user_id, PlayerJamMember { name: host_name.to_string(), status: PlayerJamMemberStatus::Joined, last_seen_ms: now, }, ); seen.insert(host_user_id); for (user_id, name) in invitees.into_iter().take(PLAYER_JAM_MAX_INVITEES) { if !seen.insert(user_id) { continue; } members.insert( user_id, PlayerJamMember { name, status: PlayerJamMemberStatus::Invited, last_seen_ms: 0, }, ); } let jam_id = uuid::Uuid::new_v4().simple().to_string(); let jam = PlayerJamSession { id: jam_id.clone(), host_user_id, host_name: host_name.to_string(), host_last_seen_ms: now, members, }; state.jams_by_id.insert(jam_id.clone(), jam); Ok(self.snapshot_locked(&state, host_user_id, current_device_id, Some(&jam_id), now)) } fn join_jam( &self, user_id: i64, user_name: &str, device_id: &str, jam_id: &str, ) -> Result { let now = current_millis(); let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); let Some(jam) = state.jams_by_id.get_mut(jam_id) else { return Err("jam is not available"); }; let Some(member) = jam.members.get_mut(&user_id) else { return Err("jam is not available"); }; member.name = user_name.to_string(); member.status = PlayerJamMemberStatus::Joined; member.last_seen_ms = now; if user_id == jam.host_user_id { jam.host_last_seen_ms = now; } Ok(self.snapshot_locked(&state, user_id, device_id, Some(jam_id), now)) } fn invite_to_jam( &self, inviter_user_id: i64, device_id: &str, jam_id: &str, invitees: Vec<(i64, String)>, ) -> Result { let now = current_millis(); let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); let Some(jam) = state.jams_by_id.get_mut(jam_id) else { return Err("jam is not available"); }; let Some(inviter) = jam.members.get(&inviter_user_id) else { return Err("jam is not available"); }; if inviter.status != PlayerJamMemberStatus::Joined { return Err("join the jam first"); } if let Some(inviter) = jam.members.get_mut(&inviter_user_id) { inviter.last_seen_ms = now; } if inviter_user_id == jam.host_user_id { jam.host_last_seen_ms = now; } let available_slots = PLAYER_JAM_MAX_INVITEES.saturating_sub(jam.members.len()); for (user_id, name) in invitees.into_iter().take(available_slots) { if user_id == inviter_user_id || jam.members.contains_key(&user_id) { continue; } jam.members.insert( user_id, PlayerJamMember { name, status: PlayerJamMemberStatus::Invited, last_seen_ms: 0, }, ); } Ok(self.snapshot_locked(&state, inviter_user_id, device_id, Some(jam_id), now)) } fn leave_jam( &self, user_id: i64, device_id: &str, jam_id: &str, ) -> Result { let now = current_millis(); let mut state = self.state.lock().expect("player device hub lock"); self.prune_locked(&mut state, now); let Some(jam) = state.jams_by_id.get(jam_id) else { return Ok(self.snapshot_locked(&state, user_id, device_id, None, now)); }; if !jam.members.contains_key(&user_id) { return Err("jam is not available"); } if jam.host_user_id == user_id { state.jams_by_id.remove(jam_id); } else if let Some(jam) = state.jams_by_id.get_mut(jam_id) { jam.members.remove(&user_id); } Ok(self.snapshot_locked(&state, user_id, device_id, None, now)) } fn touch_jam_locked( &self, state: &mut PlayerDeviceHubState, user_id: i64, device_id: &str, current_jam_id: Option<&str>, now: i64, ) { let Some(jam_id) = current_jam_id else { return; }; let is_active_host_device = state .active_device_by_user .get(&user_id) .is_some_and(|active_id| active_id == device_id); let Some(jam) = state.jams_by_id.get_mut(jam_id) else { return; }; let Some(member) = jam.members.get_mut(&user_id) else { return; }; member.last_seen_ms = now; if member.status == PlayerJamMemberStatus::Invited { return; } if user_id == jam.host_user_id && is_active_host_device { jam.host_last_seen_ms = now; } } fn touch_host_jams_locked( &self, state: &mut PlayerDeviceHubState, user_id: i64, device_id: &str, now: i64, ) { let is_active = state .active_device_by_user .get(&user_id) .is_some_and(|active_id| active_id == device_id); if !is_active { return; } for jam in state.jams_by_id.values_mut() { if jam.host_user_id == user_id { jam.host_last_seen_ms = now; if let Some(member) = jam.members.get_mut(&user_id) { member.last_seen_ms = now; } } } } fn jam_accessible_locked( &self, state: &PlayerDeviceHubState, user_id: i64, jam_id: &str, require_joined: bool, ) -> bool { let Some(jam) = state.jams_by_id.get(jam_id) else { return false; }; let Some(member) = jam.members.get(&user_id) else { return false; }; !require_joined || member.status == PlayerJamMemberStatus::Joined } fn user_has_joined_jam_locked(&self, state: &PlayerDeviceHubState, user_id: i64) -> bool { state.jams_by_id.values().any(|jam| { jam.members .get(&user_id) .is_some_and(|member| member.status == PlayerJamMemberStatus::Joined) }) } fn jam_target_device_id_locked( &self, state: &PlayerDeviceHubState, jam: &PlayerJamSession, ) -> Option { let active_device_id = state.active_device_by_user.get(&jam.host_user_id)?; let host_devices = state.devices_by_user.get(&jam.host_user_id)?; host_devices .contains_key(active_device_id) .then(|| active_device_id.clone()) } fn playback_state_for_context_locked( &self, state: &PlayerDeviceHubState, user_id: i64, current_jam_id: Option<&str>, now: i64, ) -> Option { let playback_user_id = current_jam_id .and_then(|jam_id| state.jams_by_id.get(jam_id)) .and_then(|jam| { jam.members.get(&user_id).and_then(|member| { (member.status == PlayerJamMemberStatus::Joined).then_some(jam.host_user_id) }) }) .unwrap_or(user_id); state .playback_state_by_user .get(&playback_user_id) .cloned() .map(|playback_state| playback_state_at(playback_state, now)) } fn jam_dtos_locked( &self, state: &PlayerDeviceHubState, user_id: i64, current_jam_id: Option<&str>, now: i64, ) -> Vec { let mut jams: Vec = state .jams_by_id .values() .filter_map(|jam| { let member = jam.members.get(&user_id)?; let member_count = jam .members .values() .filter(|member| member.status == PlayerJamMemberStatus::Joined) .count() as i64; let mut members = jam .members .iter() .map(|(member_user_id, member)| PlayerJamMemberDto { user_id: *member_user_id, name: member.name.clone(), is_joined: member.status == PlayerJamMemberStatus::Joined, is_current_user: *member_user_id == user_id, last_seen_ms: now.saturating_sub(member.last_seen_ms), }) .collect::>(); members.sort_by(|a, b| { b.is_joined .cmp(&a.is_joined) .then_with(|| b.is_current_user.cmp(&a.is_current_user)) .then_with(|| a.name.cmp(&b.name)) }); let host_device_online = self.jam_target_device_id_locked(state, jam).is_some(); Some(PlayerJamDto { id: jam.id.clone(), name: format!("{}'s Jam", jam.host_name), host_user_id: jam.host_user_id, host_name: jam.host_name.clone(), is_owner: jam.host_user_id == user_id, is_member: member.status == PlayerJamMemberStatus::Joined, is_pending: member.status == PlayerJamMemberStatus::Invited, is_active: current_jam_id == Some(jam.id.as_str()), member_count, host_last_seen_ms: now.saturating_sub(jam.host_last_seen_ms), host_device_online, members, }) }) .collect(); jams.sort_by(|a, b| { b.is_active .cmp(&a.is_active) .then_with(|| b.is_owner.cmp(&a.is_owner)) .then_with(|| b.is_pending.cmp(&a.is_pending)) .then_with(|| a.name.cmp(&b.name)) }); jams } fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) { state.device_last_seen_ms.retain(|_, last_seen| { now.saturating_sub(*last_seen) <= PLAYER_DEVICE_RETURN_TAKEOVER_MS }); state .jams_by_id .retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS); state.devices_by_user.retain(|user_id, devices| { devices.retain(|_, device| { now.saturating_sub(device.last_seen_ms) <= PLAYER_DEVICE_TTL_MS }); let active_valid = state .active_device_by_user .get(user_id) .is_some_and(|active_id| devices.contains_key(active_id)); if !active_valid { if let Some(first_device_id) = devices.keys().next().cloned() { state .active_device_by_user .insert(*user_id, first_device_id); } else { state.active_device_by_user.remove(user_id); state.playback_state_by_user.remove(user_id); } } !devices.is_empty() }); state .playback_state_by_user .retain(|user_id, _| state.devices_by_user.contains_key(user_id)); state .commands_by_device .retain(|(user_id, device_id), queue| { let device_online = state .devices_by_user .get(user_id) .is_some_and(|devices| devices.contains_key(device_id)); if !device_online { return false; } queue.retain(|cmd| { now.saturating_sub(cmd.created_at_ms) <= PLAYER_DEVICE_COMMAND_TTL_MS }); !queue.is_empty() }); } } 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, ) -> PlayerDevicePlaybackStateDto { if !playback_state.paused && playback_state.updated_at_ms > 0 { let elapsed_seconds = now.saturating_sub(playback_state.updated_at_ms) as f64 / 1000.0; playback_state.position_seconds += elapsed_seconds; if playback_state.duration_seconds > 0.0 { playback_state.position_seconds = playback_state .position_seconds .min(playback_state.duration_seconds); } } playback_state.updated_at_ms = now; playback_state } fn normalize_device_id(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() || trimmed.len() > 128 { return None; } if !trimmed .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ':') { return None; } Some(trimmed.to_string()) } fn device_name_from_user_agent(user_agent: Option<&str>) -> String { if let Some(name) = native_device_name_from_user_agent(user_agent) { return name; } let ua = user_agent.unwrap_or_default().to_ascii_lowercase(); let browser = if ua.contains("edg/") || ua.contains("edgios/") || ua.contains("edga/") { "Edge" } else if ua.contains("firefox/") || ua.contains("fxios/") { "Firefox" } else if ua.contains("opr/") || ua.contains("opera") { "Opera" } else if ua.contains("chrome/") || ua.contains("crios/") { "Chrome" } else if ua.contains("safari/") { "Safari" } else { "Browser" }; let os = if ua.contains("iphone") { "iPhone" } else if ua.contains("ipad") { "iPad" } else if ua.contains("android") { "Android" } else if ua.contains("windows") { "Windows" } else if ua.contains("mac os") || ua.contains("macintosh") { "macOS" } else if ua.contains("linux") { "Linux" } else { "Device" }; format!("{browser} on {os}") } fn native_device_name_from_user_agent(user_agent: Option<&str>) -> Option { let raw = user_agent?.trim(); for token in raw.split_ascii_whitespace() { let Some((product, version)) = token.split_once('/') else { continue; }; let version = sanitize_user_agent_version(version); if product.eq_ignore_ascii_case("FurumiAndroid") { return Some(match version.as_deref() { Some(v) => format!("Furumi Android {v}"), None => "Furumi Android".to_string(), }); } if product.eq_ignore_ascii_case("FurumiMacOS") { return Some(match version.as_deref() { Some(v) => format!("Furumi MacOS {v}"), None => "Furumi MacOS".to_string(), }); } if product.eq_ignore_ascii_case("FurumiTUI") || product.eq_ignore_ascii_case("furumi-tui") { return Some(match version.as_deref() { Some(v) => format!("Furumi TUI {v}"), None => "Furumi TUI".to_string(), }); } } None } fn sanitize_user_agent_version(version: &str) -> Option { let version = version .chars() .take(32) .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_')) .collect::(); if version.is_empty() { None } else { Some(version) } } fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str { let ua = user_agent.unwrap_or_default().to_ascii_lowercase(); if ua.contains("furumiandroid/") { return if ua.contains("tablet") || (ua.contains("android") && !ua.contains("mobile")) { "tablet" } else { "phone" }; } if ua.contains("furumimac") { return "computer"; } if ua.contains("furumitui/") || ua.contains("furumi-tui/") { return "computer"; } if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) { "phone" } else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") { "tablet" } else { "computer" } } #[cfg(test)] mod device_tests { use super::*; #[test] fn detects_furumi_android_native_client() { let user_agent = Some("FurumiAndroid/1.0 Android Mobile"); assert_eq!( device_name_from_user_agent(user_agent), "Furumi Android 1.0" ); assert_eq!(device_kind_from_user_agent(user_agent), "phone"); } #[test] fn detects_furumi_tui_native_client() { let user_agent = Some("FurumiTUI/0.1.0 macos"); assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0"); assert_eq!(device_kind_from_user_agent(user_agent), "computer"); } #[test] fn detects_furumi_tui_http_user_agent_token() { let user_agent = Some("furumi-tui/0.1.0 (macos)"); assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0"); assert_eq!(device_kind_from_user_agent(user_agent), "computer"); } #[test] fn keeps_browser_fallback_for_generic_android_user_agents() { let user_agent = Some("Mozilla/5.0 Android Mobile"); assert_eq!( device_name_from_user_agent(user_agent), "Browser on Android" ); 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()) ); } #[test] fn federated_snapshot_does_not_steal_active_browser_playback() { let hub = PlayerDeviceHub::default(); let user_id = 7; { let mut state = hub.state.lock().expect("device hub"); state.devices_by_user.entry(user_id).or_default().insert( "browser".to_string(), PlayerDevice { id: "browser".to_string(), name: "Browser".to_string(), kind: "computer".to_string(), last_seen_ms: current_millis(), }, ); state .active_device_by_user .insert(user_id, "browser".to_string()); state.playback_state_by_user.insert( user_id, PlayerDevicePlaybackStateDto { track: Some(serde_json::json!({"id": 1})), tracks: vec![], index: 0, position_seconds: 10.0, duration_seconds: 100.0, paused: false, shuffle: false, repeat_mode: "off".to_string(), volume: 0.7, updated_at_ms: current_millis(), }, ); } hub.apply_fed_playback_state_json( user_id, "remote", "Remote", true, serde_json::json!({ "track": {"id": 2}, "tracks": [], "index": 0, "position_seconds": 0.0, "duration_seconds": 100.0, "paused": false, "shuffle": false, "repeat_mode": "off", "volume": 0.7 }), ) .expect("valid snapshot"); let state = hub.state.lock().expect("device hub"); assert_eq!( state .active_device_by_user .get(&user_id) .map(String::as_str), Some("browser") ); assert!( state .devices_by_user .get(&user_id) .is_some_and(|devices| devices.contains_key("fed:remote")) ); } #[test] fn new_browser_claims_an_idle_federated_player() { let hub = PlayerDeviceHub::default(); let user_id = 8; hub.apply_fed_playback_state_json( user_id, "remote", "Remote", true, serde_json::json!({ "track": {"id": 2}, "tracks": [], "index": 0, "position_seconds": 12.0, "duration_seconds": 100.0, "paused": true, "shuffle": false, "repeat_mode": "off", "volume": 0.7 }), ) .expect("valid snapshot"); let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None); assert_eq!(response.active_device_id.as_deref(), Some("browser")); assert_eq!(previous.as_deref(), Some("fed:remote")); } #[test] fn new_browser_does_not_claim_a_playing_federated_player() { let hub = PlayerDeviceHub::default(); let user_id = 9; hub.apply_fed_playback_state_json( user_id, "remote", "Remote", true, serde_json::json!({ "track": {"id": 2}, "tracks": [], "index": 0, "position_seconds": 12.0, "duration_seconds": 100.0, "paused": false, "shuffle": false, "repeat_mode": "off", "volume": 0.7 }), ) .expect("valid snapshot"); let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None); assert_eq!(response.active_device_id.as_deref(), Some("fed:remote")); assert_eq!(previous, None); } #[test] fn refreshed_control_browser_keeps_its_control_role() { let hub = PlayerDeviceHub::default(); let user_id = 10; hub.apply_fed_playback_state_json( user_id, "remote", "Remote", true, serde_json::json!({ "track": {"id": 2}, "tracks": [], "index": 0, "position_seconds": 12.0, "duration_seconds": 100.0, "paused": true, "shuffle": false, "repeat_mode": "off", "volume": 0.7 }), ) .expect("valid snapshot"); { let mut state = hub.state.lock().expect("device hub"); let now = current_millis(); state.devices_by_user.entry(user_id).or_default().insert( "browser".to_string(), PlayerDevice { id: "browser".to_string(), name: "Browser".to_string(), kind: "computer".to_string(), last_seen_ms: now, }, ); state .device_last_seen_ms .insert((user_id, "browser".to_string()), now); } let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None); assert_eq!(response.active_device_id.as_deref(), Some("fed:remote")); assert_eq!(previous, None); } } #[derive(Debug, sqlx::FromRow)] struct LastfmAccountApiRow { session_key: String, reauth_required: bool, last_error: Option, } #[derive(Debug, sqlx::FromRow)] struct LastfmStatusRow { username: String, reauth_required: bool, last_error: Option, } #[derive(Debug, sqlx::FromRow)] struct LastfmTrackMetaRow { title: String, duration_seconds: f64, track_number: Option, album_title: Option, artist_name: Option, album_artist_name: Option, } #[derive(Debug, serde::Deserialize)] struct LastfmCallbackQuery { token: Option, state: Option, } // --------------------------------------------------------------------------- // SPA shell // --------------------------------------------------------------------------- #[derive(Debug, Template)] #[template(path = "player.html")] pub struct PlayerPageTemplate { pub t: &'static Translations, pub downloads_enabled: bool, pub torrent_downloads_enabled: bool, pub youtube_downloads_enabled: bool, } #[cfg(test)] mod page_template_tests { use super::*; use crate::i18n::Lang; #[test] fn download_manager_button_follows_the_global_switch() { let disabled = PlayerPageTemplate { t: Translations::for_lang(Lang::En), downloads_enabled: false, torrent_downloads_enabled: false, youtube_downloads_enabled: false, } .render() .unwrap(); assert!(!disabled.contains("