Skip to main content

#
jellytau_lib/session_poller/
mod.rs

    1//! Session polling manager for remote playback control.
    2//!
    3//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
    4//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
    5//!
    6//! TRACES: UR-010 | JA-021
    7
    8use crate::utils::lock::{MutexSafe, RwLockSafe};
    9use log::{debug, info, warn};
    10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
    11use std::sync::{Arc, Mutex, RwLock};
    12use std::thread;
    13use std::time::Duration;
    14
    15use crate::jellyfin::JellyfinClient;
    16use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
    17use crate::player::PlayerEventEmitter;
    18
    19/// Hint for adjusting poll frequency based on UI state
    20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    21pub enum PollingHint {
    22    /// CastButton is active and needs frequent updates (500ms)
    23    CastActive,
    24    /// CastButton is in discovery mode (15s)
    25    CastDiscovery,
    26    /// No special hint, use mode-based frequency (default)
    27    Normal,
    28}
    29
    30/// Manages background polling of Jellyfin sessions
    31pub struct SessionPollerManager {
    32    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
    33    playback_mode_manager: Arc<PlaybackModeManager>,
    34    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
    35    /// Optional connectivity reporter. The session poller is the one piece of
    36    /// server traffic that runs continuously even when the user is idle (not
    37    /// browsing the library), so feeding its poll outcomes into the reporter is
    38    /// what lets the app detect going offline — and, crucially, recover when the
    39    /// server returns — without any user interaction. Repository traffic alone
    40    /// can't do this because it only happens while browsing.
    41    connectivity_reporter: Arc<Mutex<Option<crate::connectivity::ConnectivityReporter>>>,
    42
    43    // Polling state
    44    is_running: Arc<AtomicBool>,
    45    current_hint: Arc<RwLock<PollingHint>>,
    46    current_interval_ms: Arc<AtomicU64>,
    47
    48    // Thread handle (for cleanup)
    49    thread_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
    50}
    51
    52impl SessionPollerManager {
    53    /// Create a new SessionPollerManager
    54    pub fn new(
    55        jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
    56        playback_mode_manager: Arc<PlaybackModeManager>,
    57    ) -> Self {
    58        Self {
    59            jellyfin_client,
    60            playback_mode_manager,
    61            event_emitter: Arc::new(Mutex::new(None)),
    62            connectivity_reporter: Arc::new(Mutex::new(None)),
    63            is_running: Arc::new(AtomicBool::new(false)),
    64            current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
    65            current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
    66            thread_handle: Arc::new(Mutex::new(None)),
    67        }
    68    }
    69
    70    /// Set event emitter for broadcasting session updates
    71    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
    72        *self.event_emitter.lock_safe() = Some(emitter);
    73    }
    74
    75    /// Wire the connectivity reporter so each poll outcome updates reachability.
    76    /// A successful poll recovers the app to online instantly; sustained poll
    77    /// failures flip it offline (subject to the reporter's debounce window).
    78    pub fn set_connectivity_reporter(&self, reporter: crate::connectivity::ConnectivityReporter) {
    79        *self.connectivity_reporter.lock_safe() = Some(reporter);
    80    }
    81
    82    /// Start the background polling thread
    83    pub fn start(&self) {
    84        if self.is_running.swap(true, Ordering::Relaxed) {
    85            warn!("[SessionPoller] Already running, ignoring start request");
    86            return;
    87        }
    88
    89        info!("[SessionPoller] Starting session polling");
    90
    91        // Clone Arc references for the thread
    92        let client = self.jellyfin_client.clone();
    93        let mode_manager = self.playback_mode_manager.clone();
    94        let emitter = self.event_emitter.clone();
    95        let connectivity_reporter = self.connectivity_reporter.clone();
    96        let is_running = self.is_running.clone();
    97        let hint = self.current_hint.clone();
    98        let interval_ms = self.current_interval_ms.clone();
    99
    100        let handle = thread::spawn(move || {
    101            // Create Tokio runtime for async operations in this thread
    102            let rt = tokio::runtime::Runtime::new().unwrap();
    103
    104            while is_running.load(Ordering::Relaxed) {
    105                // Calculate poll interval based on mode and hint
    106                let new_interval =
    107                    Self::calculate_interval(&mode_manager.get_mode(), *hint.read_safe());
    108
    109                interval_ms.store(new_interval, Ordering::Relaxed);
    110
    111                debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
    112
    113                // Fetch sessions. `had_client` distinguishes "server didn't
    114                // answer" from "no client configured" so we only feed real
    115                // request outcomes into the connectivity reporter.
    116                let (sessions_result, had_client) = rt.block_on(async {
    117                    let client_opt = client.lock_safe().clone();
    118                    match client_opt {
    119                        Some(c) => (c.get_sessions().await, true),
    120                        None => {
    121                            debug!("[SessionPoller] Jellyfin client not configured, skipping poll");
    122                            (Ok(Vec::new()), false)
    123                        }
    124                    }
    125                });
    126
    127                // Drive the connectivity reporter from this poll's outcome. This
    128                // is what recovers the app to online when the server returns
    129                // while the user is idle, and detects going offline when no
    130                // library browsing is happening. See connectivity/mod.rs.
    131                if had_client {
    132                    if let Some(reporter) = connectivity_reporter.lock_safe().clone() {
    133                        rt.block_on(async {
    134                            match &sessions_result {
    135                                Ok(_) => reporter.report_success().await,
    136                                Err(e) => reporter.report_network_failure(Some(e.clone())).await,
    137                            }
    138                        });
    139                    }
    140                }
    141
    142                // Emit event if successful
    143                match sessions_result {
    144                    Ok(sessions) => {
    145                        debug!("[SessionPoller] Fetched {} sessions", sessions.len());
    146
    147                        // In remote (cast) mode, mirror the remote session's
    148                        // now-playing onto the Android lockscreen. The local
    149                        // ExoPlayer is idle while casting, so without this the
    150                        // lockscreen shows stale local metadata and a frozen
    151                        // scrubber. Driving it here (native poll thread) rather
    152                        // than from the WebView keeps it live even when the
    153                        // screen is locked and JS timers are throttled.
    154                        if let PlaybackMode::Remote { session_id } = mode_manager.get_mode() {
    155                            Self::push_remote_lockscreen(&sessions, &session_id);
    156                        }
    157
    158                        if let Some(em) = emitter.lock_safe().as_ref() {
    159                            em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { sessions });
    160                        }
    161                    }
    162                    Err(e) => {
    163                        warn!("[SessionPoller] Failed to fetch sessions: {}", e);
    164                    }
    165                }
    166
    167                // Sleep for the calculated interval
    168                thread::sleep(Duration::from_millis(new_interval));
    169            }
    170
    171            info!("[SessionPoller] Polling thread stopped");
    172        });
    173
    174        *self.thread_handle.lock_safe() = Some(handle);
    175    }
    176
    177    /// Stop the polling thread
    178    pub fn stop(&self) {
    179        info!("[SessionPoller] Stopping session polling");
    180        self.is_running.store(false, Ordering::Relaxed);
    181
    182        // Join the thread if possible (don't block indefinitely)
    183        if let Some(handle) = self.thread_handle.lock_safe().take() {
    184            let _ = handle.join();
    185        }
    186    }
    187
    188    /// Set UI hint for polling frequency adjustment
    189    pub fn set_polling_hint(&self, hint: PollingHint) {
    190        debug!("[SessionPoller] Setting polling hint: {:?}", hint);
    191        *self.current_hint.write_safe() = hint;
    192    }
    193
    194    /// Push the remote session's now-playing onto the Android lockscreen.
    195    ///
    196    /// Looks up the active remote session by id and forwards its title/artist/
    197    /// album, duration and position to the media notification. Silently does
    198    /// nothing if the session isn't found or has no now-playing item (e.g. the
    199    /// remote stopped) - the next state change will refresh it.
    200    fn push_remote_lockscreen(sessions: &[crate::jellyfin::client::SessionInfo], session_id: &str) {
    201        // 100ns Jellyfin ticks -> milliseconds.
    202        const TICKS_PER_MS: i64 = 10_000;
    203
    204        let Some(session) = sessions
    205            .iter()
    206            .find(|s| s.id.as_deref() == Some(session_id))
    207        else {
    208            return;
    209        };
    210
    211        let Some(now_playing) = session.now_playing_item.as_ref() else {
    212            return;
    213        };
    214
    215        let title = now_playing.name.clone().unwrap_or_default();
    216        let artist = now_playing
    217            .artists
    218            .as_ref()
    219            .map(|a| a.join(", "))
    220            .filter(|s| !s.is_empty())
    221            .or_else(|| now_playing.album_artist.clone())
    222            .unwrap_or_default();
    223        let album = now_playing.album.clone();
    224        let duration_ms = now_playing.run_time_ticks.unwrap_or(0) / TICKS_PER_MS;
    225
    226        let (position_ms, is_playing) = session
    227            .play_state
    228            .as_ref()
    229            .map(|ps| {
    230                (
    231                    ps.position_ticks.unwrap_or(0) / TICKS_PER_MS,
    232                    !ps.is_paused.unwrap_or(false),
    233                )
    234            })
    235            .unwrap_or((0, false));
    236
    237        let meta = crate::player::LockscreenMetadata {
    238            title,
    239            artist,
    240            album,
    241            duration_ms,
    242            position_ms,
    243            is_playing,
    244        };
    245
    246        if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
    247            warn!(
    248                "[SessionPoller] Failed to update lockscreen metadata: {}",
    249                e
    250            );
    251        }
    252    }
    253
    254    /// Calculate polling interval based on mode and hint
    255    fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
    256        match hint {
    257            PollingHint::CastActive => 500,      // Very fast for active control
    258            PollingHint::CastDiscovery => 15000, // Slow discovery
    259            PollingHint::Normal => {
    260                match mode {
    261                    PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
    262                    PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
    263                }
    264            }
    265        }
    266    }
    267
    268    /// Manually trigger a poll (for frontend refresh button)
    269    pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
    270        let client = self
    271            .jellyfin_client
    272            .lock_safe()
    273            .clone()
    274            .ok_or("Jellyfin client not configured")?;
    275
    276        client.get_sessions().await
    277    }
    278}
    279
    280impl Drop for SessionPollerManager {
    281    fn drop(&mut self) {
    282        self.stop();
    283    }
    284}
    285
    286#[cfg(test)]
    287mod tests {
    288    use super::*;
    289
    290    /// Test polling interval calculation for different modes and hints
    291    #[test]
    292    fn test_calculate_interval() {
    293        // CastActive hint should always be 500ms regardless of mode
    294        assert_eq!(
    295            SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastActive),
    296            500
    297        );
    298        assert_eq!(
    299            SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastActive),
    300            500
    301        );
    302        assert_eq!(
    303            SessionPollerManager::calculate_interval(
    304                &PlaybackMode::Remote {
    305                    session_id: "test".to_string()
    306                },
    307                PollingHint::CastActive
    308            ),
    309            500
    310        );
    311
    312        // CastDiscovery hint should always be 15s regardless of mode
    313        assert_eq!(
    314            SessionPollerManager::calculate_interval(
    315                &PlaybackMode::Idle,
    316                PollingHint::CastDiscovery
    317            ),
    318            15000
    319        );
    320        assert_eq!(
    321            SessionPollerManager::calculate_interval(
    322                &PlaybackMode::Local,
    323                PollingHint::CastDiscovery
    324            ),
    325            15000
    326        );
    327        assert_eq!(
    328            SessionPollerManager::calculate_interval(
    329                &PlaybackMode::Remote {
    330                    session_id: "test".to_string()
    331                },
    332                PollingHint::CastDiscovery
    333            ),
    334            15000
    335        );
    336
    337        // Normal hint should depend on mode
    338        // Idle and Local modes -> 10s
    339        assert_eq!(
    340            SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::Normal),
    341            10000
    342        );
    343        assert_eq!(
    344            SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::Normal),
    345            10000
    346        );
    347        // Remote mode -> 2s
    348        assert_eq!(
    349            SessionPollerManager::calculate_interval(
    350                &PlaybackMode::Remote {
    351                    session_id: "test".to_string()
    352                },
    353                PollingHint::Normal
    354            ),
    355            2000
    356        );
    357    }
    358
    359    /// Test PollingHint enum equality
    360    #[test]
    361    fn test_polling_hint_equality() {
    362        assert_eq!(PollingHint::Normal, PollingHint::Normal);
    363        assert_eq!(PollingHint::CastActive, PollingHint::CastActive);
    364        assert_eq!(PollingHint::CastDiscovery, PollingHint::CastDiscovery);
    365
    366        assert_ne!(PollingHint::Normal, PollingHint::CastActive);
    367        assert_ne!(PollingHint::CastActive, PollingHint::CastDiscovery);
    368    }
    369}