Duncan Tourolle 6866f03c55 Architecture remediation A/B/F: poison-tolerant locks, graceful backend init, doc fixes
Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
  poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
  sites with _safe variants across 14 files, eliminating the player
  crash-cascade class. Tokio async mutexes are unchanged.

Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
  it falls back to NullBackend and emits a backend-init-failed event so the UI
  can show "playback unavailable" instead of the app crashing. Fatal DB-setup
  panics are kept.

Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
  reflect reality (~20.5k non-test frontend) and document the events+polling
  hybrid plus the new locking/backend-init behavior.
2026-06-20 16:03:54 +02:00

254 lines
8.8 KiB
Rust

//! Session polling manager for remote playback control.
//!
//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
//!
//! TRACES: UR-010 | JA-021
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, info, warn};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
use std::time::Duration;
use crate::jellyfin::JellyfinClient;
use crate::player::PlayerEventEmitter;
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
/// Hint for adjusting poll frequency based on UI state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PollingHint {
/// CastButton is active and needs frequent updates (500ms)
CastActive,
/// CastButton is in discovery mode (15s)
CastDiscovery,
/// No special hint, use mode-based frequency (default)
Normal,
}
/// Manages background polling of Jellyfin sessions
pub struct SessionPollerManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
playback_mode_manager: Arc<PlaybackModeManager>,
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
// Polling state
is_running: Arc<AtomicBool>,
current_hint: Arc<RwLock<PollingHint>>,
current_interval_ms: Arc<AtomicU64>,
// Thread handle (for cleanup)
thread_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
}
impl SessionPollerManager {
/// Create a new SessionPollerManager
pub fn new(
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
playback_mode_manager: Arc<PlaybackModeManager>,
) -> Self {
Self {
jellyfin_client,
playback_mode_manager,
event_emitter: Arc::new(Mutex::new(None)),
is_running: Arc::new(AtomicBool::new(false)),
current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
thread_handle: Arc::new(Mutex::new(None)),
}
}
/// Set event emitter for broadcasting session updates
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter);
}
/// Start the background polling thread
pub fn start(&self) {
if self.is_running.swap(true, Ordering::Relaxed) {
warn!("[SessionPoller] Already running, ignoring start request");
return;
}
info!("[SessionPoller] Starting session polling");
// Clone Arc references for the thread
let client = self.jellyfin_client.clone();
let mode_manager = self.playback_mode_manager.clone();
let emitter = self.event_emitter.clone();
let is_running = self.is_running.clone();
let hint = self.current_hint.clone();
let interval_ms = self.current_interval_ms.clone();
let handle = thread::spawn(move || {
// Create Tokio runtime for async operations in this thread
let rt = tokio::runtime::Runtime::new().unwrap();
while is_running.load(Ordering::Relaxed) {
// Calculate poll interval based on mode and hint
let new_interval = Self::calculate_interval(
&mode_manager.get_mode(),
*hint.read_safe(),
);
interval_ms.store(new_interval, Ordering::Relaxed);
debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
// Fetch sessions
let sessions_result = rt.block_on(async {
let client_opt = client.lock_safe().clone();
match client_opt {
Some(c) => c.get_sessions().await,
None => {
debug!("[SessionPoller] Jellyfin client not configured, skipping poll");
Ok(Vec::new())
}
}
});
// Emit event if successful
match sessions_result {
Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions,
});
}
}
Err(e) => {
warn!("[SessionPoller] Failed to fetch sessions: {}", e);
}
}
// Sleep for the calculated interval
thread::sleep(Duration::from_millis(new_interval));
}
info!("[SessionPoller] Polling thread stopped");
});
*self.thread_handle.lock_safe() = Some(handle);
}
/// Stop the polling thread
pub fn stop(&self) {
info!("[SessionPoller] Stopping session polling");
self.is_running.store(false, Ordering::Relaxed);
// Join the thread if possible (don't block indefinitely)
if let Some(handle) = self.thread_handle.lock_safe().take() {
let _ = handle.join();
}
}
/// Set UI hint for polling frequency adjustment
pub fn set_polling_hint(&self, hint: PollingHint) {
debug!("[SessionPoller] Setting polling hint: {:?}", hint);
*self.current_hint.write_safe() = hint;
}
/// Calculate polling interval based on mode and hint
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
match hint {
PollingHint::CastActive => 500, // Very fast for active control
PollingHint::CastDiscovery => 15000, // Slow discovery
PollingHint::Normal => {
match mode {
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
}
}
}
}
/// Manually trigger a poll (for frontend refresh button)
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
let client = self.jellyfin_client.lock_safe().clone()
.ok_or("Jellyfin client not configured")?;
client.get_sessions().await
}
}
impl Drop for SessionPollerManager {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test polling interval calculation for different modes and hints
#[test]
fn test_calculate_interval() {
// CastActive hint should always be 500ms regardless of mode
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastActive),
500
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastActive),
500
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
PollingHint::CastActive
),
500
);
// CastDiscovery hint should always be 15s regardless of mode
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastDiscovery),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastDiscovery),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
PollingHint::CastDiscovery
),
15000
);
// Normal hint should depend on mode
// Idle and Local modes -> 10s
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::Normal),
10000
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::Normal),
10000
);
// Remote mode -> 2s
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
PollingHint::Normal
),
2000
);
}
/// Test PollingHint enum equality
#[test]
fn test_polling_hint_equality() {
assert_eq!(PollingHint::Normal, PollingHint::Normal);
assert_eq!(PollingHint::CastActive, PollingHint::CastActive);
assert_eq!(PollingHint::CastDiscovery, PollingHint::CastDiscovery);
assert_ne!(PollingHint::Normal, PollingHint::CastActive);
assert_ne!(PollingHint::CastActive, PollingHint::CastDiscovery);
}
}