use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use tauri::{AppHandle, Emitter}; use serde::{Serialize, Deserialize}; use crate::jellyfin::http_client::HttpClient; // Offline recovery probe interval. // Reachability while online is driven by real repository traffic, so there is // no online polling. While offline we probe quickly to detect the server // returning even when no user traffic is flowing. const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline // Time-window debounce for declaring the server offline. // A single dropped request must not trip the banner: we only flip to offline // once network failures have persisted continuously for this window with no // intervening success. Recovery (online) is instant on the first success. const OFFLINE_CONFIRM_WINDOW: Duration = Duration::from_secs(5); /// Connectivity status #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectivityStatus { /// Whether the Jellyfin server is reachable pub is_server_reachable: bool, /// Last time we checked server reachability (ISO 8601 string) pub last_checked: Option, /// Error message from last connectivity check pub connection_error: Option, /// Whether we're currently checking connectivity pub is_checking: bool, } impl Default for ConnectivityStatus { fn default() -> Self { Self { // Start optimistic - assume online until proven otherwise // This prevents the app from appearing offline on startup is_server_reachable: true, last_checked: None, connection_error: None, is_checking: false, } } } /// Connectivity change event emitted to frontend #[derive(specta::Type, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ConnectivityChangeEvent { is_reachable: bool, } /// Shared reachability state and transition logic. /// /// This is the single place that mutates reachability and emits events. It is /// cheap to clone (all fields are `Arc`/`Option`) and is shared by: /// - the `ConnectivityMonitor` (commands, offline recovery probe), and /// - `OnlineRepository`, which reports the outcome of every server request. /// /// Reachability is therefore driven by real traffic; the probe only fills the /// gap while offline. #[derive(Clone)] pub struct ConnectivityReporter { status: Arc>, /// Timestamp of the first network failure in the current failure streak. /// Used to debounce the transition to offline (see `OFFLINE_CONFIRM_WINDOW`). first_failure_at: Arc>>, app_handle: Option, } impl ConnectivityReporter { fn new(status: Arc>, app_handle: Option) -> Self { Self { status, first_failure_at: Arc::new(RwLock::new(None)), app_handle, } } /// Current reachability as seen by this reporter (shared with the monitor /// and the UI). Useful for callers that want to branch on connectivity. #[allow(dead_code)] // public API; currently only exercised by cross-module tests pub async fn is_reachable(&self) -> bool { self.status.read().await.is_server_reachable } /// Test-only: force the reporter into the offline state without going through /// the debounce, so other modules' tests can set up an "offline" precondition. #[cfg(test)] pub async fn mark_unreachable_for_test(&self) { self.apply_probe_result(false, Some("forced offline (test)".to_string())) .await; } /// Report that a real server request succeeded (or that the server answered /// at all, e.g. with 401/404/5xx). The server is up — recover instantly. pub async fn report_success(&self) { *self.first_failure_at.write().await = None; self.set_reachable(true, None).await; } /// Report that a real server request failed with a network-level error /// (connection refused, timeout, DNS). Subject to the time-window debounce: /// we only flip to offline once failures have persisted for /// `OFFLINE_CONFIRM_WINDOW` with no intervening success. pub async fn report_network_failure(&self, error: Option) { // If already offline, nothing to debounce. if !self.status.read().await.is_server_reachable { return; } let now = Instant::now(); let streak_start = { let mut first = self.first_failure_at.write().await; *first.get_or_insert(now) }; if now.duration_since(streak_start) >= OFFLINE_CONFIRM_WINDOW { log::warn!( "[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline", OFFLINE_CONFIRM_WINDOW ); self.set_reachable(false, error).await; } else { log::debug!( "[ConnectivityMonitor] Network failure within debounce window; not yet offline" ); } } /// Apply a deliberate reachability probe result (offline recovery probe or a /// manual check). Unlike `report_network_failure`, a probe is an explicit /// reachability test, so its result is applied immediately without debounce. async fn apply_probe_result(&self, is_reachable: bool, error: Option) { if is_reachable { *self.first_failure_at.write().await = None; } self.set_reachable(is_reachable, error).await; } /// Core transition: update status and emit events only on an actual change. async fn set_reachable(&self, is_reachable: bool, error: Option) { let was_reachable = { let mut status = self.status.write().await; let was = status.is_server_reachable; status.is_server_reachable = is_reachable; status.last_checked = Some(chrono::Utc::now().to_rfc3339()); status.connection_error = if is_reachable { None } else { Some(error.unwrap_or_else(|| "Server unreachable".to_string())) }; status.is_checking = false; was }; if is_reachable != was_reachable { self.emit_connectivity_change(is_reachable).await; if is_reachable { self.emit_server_reconnected().await; } } } /// Emit connectivity change event to frontend async fn emit_connectivity_change(&self, is_reachable: bool) { if let Some(app_handle) = &self.app_handle { let event = ConnectivityChangeEvent { is_reachable }; if let Err(e) = app_handle.emit("connectivity:changed", event) { log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e); } else { log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable); } } } /// Emit server reconnected event to frontend async fn emit_server_reconnected(&self) { if let Some(app_handle) = &self.app_handle { if let Err(e) = app_handle.emit("connectivity:reconnected", ()) { log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e); } else { log::info!("[ConnectivityMonitor] Emitted server reconnected event"); } } } } /// Connectivity monitor for tracking server reachability. /// /// Reachability is driven primarily by real repository traffic via the shared /// [`ConnectivityReporter`]. The monitor itself only runs an offline recovery /// probe (see `start_monitoring`) and serves the connectivity Tauri commands. pub struct ConnectivityMonitor { server_url: Arc>>, http_client: Arc, reporter: ConnectivityReporter, is_monitoring: Arc, } impl ConnectivityMonitor { /// Create a new connectivity monitor pub fn new(http_client: HttpClient) -> Self { let status = Arc::new(RwLock::new(ConnectivityStatus::default())); Self { server_url: Arc::new(RwLock::new(None)), http_client: Arc::new(http_client), reporter: ConnectivityReporter::new(status, None), is_monitoring: Arc::new(AtomicBool::new(false)), } } /// Set the Tauri app handle for event emission. /// Must be called before the reporter is shared with the repository. pub fn set_app_handle(&mut self, app_handle: AppHandle) { self.reporter.app_handle = Some(app_handle); } /// Get a cheap, cloneable reporter so the repository can feed server /// outcomes into the same reachability state the UI observes. pub fn reporter(&self) -> ConnectivityReporter { self.reporter.clone() } /// Update the server URL pub async fn set_server_url(&self, url: String) { log::info!("[ConnectivityMonitor] Setting server URL: {}", url); *self.server_url.write().await = Some(url); // Check new server immediately log::info!("[ConnectivityMonitor] Checking reachability of new server..."); let is_reachable = self.check_reachability().await; log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" }); } /// Get current connectivity status pub async fn get_status(&self) -> ConnectivityStatus { self.reporter.status.read().await.clone() } /// Deliberately probe the server's reachability (manual check / recovery probe). /// The result is applied immediately (no debounce) since this is an explicit test. pub async fn check_reachability(&self) -> bool { { let mut status = self.reporter.status.write().await; status.is_checking = true; } let server_url = self.server_url.read().await.clone(); let Some(url) = server_url else { log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured"); self.reporter .apply_probe_result(false, Some("No server URL configured".to_string())) .await; return false; }; let ping_url = format!("{}/System/Info/Public", url); log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url); let is_reachable = self.http_client.ping(&ping_url).await; log::debug!( "[ConnectivityMonitor] Ping result: {}", if is_reachable { "SUCCESS" } else { "FAILED" } ); self.reporter.apply_probe_result(is_reachable, None).await; is_reachable } /// Mark server as reachable (called after successful API call / login) pub async fn mark_reachable(&self) { self.reporter.report_success().await; } /// Mark server as unreachable directly. /// /// Used by deliberate signals (e.g. a failed login/connect) where the caller /// knows the server is unreachable now. Repository traffic should prefer /// `reporter().report_network_failure()` so the debounce applies. pub async fn mark_unreachable(&self, error: Option) { self.reporter.apply_probe_result(false, error).await; } /// Start the offline recovery probe. /// /// While **online**, reachability is kept fresh by real traffic, so the probe /// idles. While **offline**, it polls `/System/Info/Public` every /// `RETRY_CHECK_INTERVAL_MS` to detect the server returning even when no user /// traffic is flowing. pub async fn start_monitoring(&self) { if self.is_monitoring.swap(true, Ordering::SeqCst) { log::info!("[ConnectivityMonitor] Already monitoring"); return; } log::info!("[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)"); // Perform an immediate check so startup reflects reality quickly. let is_reachable = self.check_reachability().await; log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" }); let is_monitoring = Arc::clone(&self.is_monitoring); let server_url = Arc::clone(&self.server_url); let http_client = Arc::clone(&self.http_client); let reporter = self.reporter.clone(); tokio::spawn(async move { while is_monitoring.load(Ordering::SeqCst) { tokio::time::sleep(Duration::from_millis(RETRY_CHECK_INTERVAL_MS)).await; if !is_monitoring.load(Ordering::SeqCst) { break; } // Only probe while offline — real traffic is the signal when online. if reporter.status.read().await.is_server_reachable { continue; } let Some(url) = server_url.read().await.clone() else { continue; }; let ping_url = format!("{}/System/Info/Public", url); let is_reachable = http_client.ping(&ping_url).await; // Probe only ever recovers us to online; a failed probe leaves us // offline without re-emitting (no change). if is_reachable { reporter.apply_probe_result(true, None).await; } } log::info!("[ConnectivityMonitor] Stopped monitoring"); }); } /// Stop monitoring connectivity pub fn stop_monitoring(&self) { log::info!("[ConnectivityMonitor] Stopping connectivity monitoring"); self.is_monitoring.store(false, Ordering::SeqCst); } } #[cfg(test)] mod tests { use super::*; /// Build a reporter backed by a fresh (optimistic) status, with no app handle. /// Event emission is a no-op without a handle, which is exactly what we want /// for unit-testing the reachability state transitions. fn test_reporter() -> ConnectivityReporter { ConnectivityReporter::new(Arc::new(RwLock::new(ConnectivityStatus::default())), None) } async fn is_reachable(reporter: &ConnectivityReporter) -> bool { reporter.status.read().await.is_server_reachable } #[test] fn test_intervals() { // Offline recovery probe interval (online has no polling). assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000); assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5)); } #[tokio::test] async fn test_default_status() { let status = ConnectivityStatus::default(); // Default is now optimistic (assume online until proven otherwise) assert!(status.is_server_reachable); assert!(status.last_checked.is_none()); assert!(status.connection_error.is_none()); assert!(!status.is_checking); } /// A single (or brief) network failure must NOT flip the app offline: /// the time-window debounce keeps us online until the failure persists. /// /// @req-test: UR-002 - Access media when online or offline #[tokio::test] async fn test_single_network_failure_does_not_go_offline() { let reporter = test_reporter(); assert!(is_reachable(&reporter).await, "starts online"); reporter .report_network_failure(Some("timeout".to_string())) .await; assert!( is_reachable(&reporter).await, "one network failure within the debounce window stays online" ); // But the failure streak is now being tracked. assert!(reporter.first_failure_at.read().await.is_some()); } /// Once failures persist past OFFLINE_CONFIRM_WINDOW, we flip offline. /// We simulate elapsed time by backdating the streak start. /// /// @req-test: UR-002 - Access media when online or offline #[tokio::test] async fn test_sustained_network_failure_goes_offline() { let reporter = test_reporter(); // First failure starts the streak. reporter.report_network_failure(None).await; assert!(is_reachable(&reporter).await); // Backdate the streak start to before the window. { let mut first = reporter.first_failure_at.write().await; *first = Some(Instant::now() - OFFLINE_CONFIRM_WINDOW - Duration::from_secs(1)); } // Next failure now exceeds the window → offline. reporter .report_network_failure(Some("connection refused".to_string())) .await; assert!( !is_reachable(&reporter).await, "sustained network failure flips to offline" ); } /// A success during a failure streak clears the streak and keeps us online — /// recovery is instant and never trips the banner. #[tokio::test] async fn test_success_clears_failure_streak() { let reporter = test_reporter(); reporter.report_network_failure(None).await; assert!(reporter.first_failure_at.read().await.is_some()); reporter.report_success().await; assert!(is_reachable(&reporter).await); assert!( reporter.first_failure_at.read().await.is_none(), "success resets the debounce streak" ); } /// First success after being offline recovers instantly (no debounce on the /// way back up). #[tokio::test] async fn test_recovery_is_instant() { let reporter = test_reporter(); // Force offline. reporter.apply_probe_result(false, Some("down".to_string())).await; assert!(!is_reachable(&reporter).await); // A single success brings us straight back online. reporter.report_success().await; assert!(is_reachable(&reporter).await); let status = reporter.status.read().await; assert!(status.connection_error.is_none()); } /// Server-answered errors (401/404/5xx) are reported via report_success /// by the repository, because the server is demonstrably reachable. This /// test documents that contract: report_success means "server is up". #[tokio::test] async fn test_server_answered_error_counts_as_reachable() { let reporter = test_reporter(); // Simulate being offline, then the server answers (even with an error). reporter.apply_probe_result(false, None).await; assert!(!is_reachable(&reporter).await); // Repository maps Authentication/NotFound/Server errors to report_success. reporter.report_success().await; assert!( is_reachable(&reporter).await, "a server that answers (even with 4xx/5xx) is reachable" ); } /// report_network_failure is a no-op once already offline (nothing to debounce, /// no duplicate events). #[tokio::test] async fn test_network_failure_noop_when_already_offline() { let reporter = test_reporter(); reporter.apply_probe_result(false, None).await; assert!(!is_reachable(&reporter).await); // Should not panic or change state. reporter.report_network_failure(Some("still down".to_string())).await; assert!(!is_reachable(&reporter).await); } }