253 lines
8.8 KiB
Rust
253 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 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().unwrap() = 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().unwrap(),
|
|
);
|
|
|
|
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().unwrap().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().unwrap().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().unwrap() = 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().unwrap().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().unwrap() = 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().unwrap().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);
|
|
}
|
|
}
|