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}