From 36be192d440bfc497b0b28f6f04ab53217439985 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 7 Jul 2026 16:22:12 +0200 Subject: [PATCH] offline mode fixes --- src-tauri/src/auth/mod.rs | 2 +- src-tauri/src/commands/catalog.rs | 15 ++++++++++ src-tauri/src/jellyfin/http_client.rs | 39 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 12 ++++++-- src-tauri/src/session_poller/mod.rs | 41 ++++++++++++++++++++++++--- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs index 9a7a601..f14dbd0 100644 --- a/src-tauri/src/auth/mod.rs +++ b/src-tauri/src/auth/mod.rs @@ -133,7 +133,7 @@ impl AuthManager { log::info!("[AuthManager] Connecting to server: {}", normalized_url); - match self.http_client.get_json_with_retry::(&endpoint).await { + match self.http_client.get_json_fast::(&endpoint).await { Ok(info) => { log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version); diff --git a/src-tauri/src/commands/catalog.rs b/src-tauri/src/commands/catalog.rs index a349d2c..2da398b 100644 --- a/src-tauri/src/commands/catalog.rs +++ b/src-tauri/src/commands/catalog.rs @@ -271,6 +271,21 @@ pub async fn resume_queued_downloads( (Arc::new(database.service()), target_dir) }; + // Recover stale downloads: rows left in 'downloading' when the app was killed + // mid-transfer are orphaned — nothing ever restarts them, so they show as + // permanently "downloading". Reset them to 'pending' and clear the stale + // stream_url so they get re-resolved and restarted from scratch below. + let recover_query = Query::new( + "UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \ + bytes_downloaded = 0, started_at = NULL \ + WHERE status = 'downloading'", + ); + match db_service.execute(recover_query).await { + Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n), + Ok(_) => {} + Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e), + } + // Resolve each row's URL against the (now reachable) repository. let repo_for_resolve = Arc::clone(&repo); let outcome = resolve_pending_download_urls( diff --git a/src-tauri/src/jellyfin/http_client.rs b/src-tauri/src/jellyfin/http_client.rs index bb076fa..7b01c79 100644 --- a/src-tauri/src/jellyfin/http_client.rs +++ b/src-tauri/src/jellyfin/http_client.rs @@ -217,6 +217,45 @@ impl HttpClient { .map_err(|e| format!("Failed to parse JSON: {}", e)) } + /// Make a GET request and deserialize JSON with a short timeout and no retries. + /// + /// Intended for the initial "connect to server" probe on the login screen: + /// a wrong/unreachable URL must fail fast instead of burning through the + /// default 30s-per-attempt timeout and exponential backoff retries. + pub async fn get_json_fast(&self, url: &str) -> Result { + // Short timeout so an unreachable host fails quickly. + const FAST_TIMEOUT: Duration = Duration::from_secs(10); + + let request = self + .client + .get(url) + .timeout(FAST_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build request: {}", e))?; + + // No retry: connection failures on a wrong URL won't succeed on retry, + // they'd only multiply the wait the user sees before an error. + let response = self + .client + .execute(request) + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("HTTP {}: {}", status, error_text)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse JSON: {}", e)) + } + /// Quick ping to check if a server is reachable (no retry) pub async fn ping(&self, url: &str) -> bool { let request = self.client.get(url) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d37cae..0043bd4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -934,9 +934,11 @@ pub fn run() { playback_mode_arc.clone(), ); session_poller.set_event_emitter(event_emitter.clone()); - session_poller.start(); + // Note: start() is deferred until after the connectivity monitor is + // created below, so the poller can report reachability from its first + // poll (it drives offline detection + recovery while the user is idle). let session_poller_arc = Arc::new(session_poller); - let session_poller_wrapper = SessionPollerWrapper(session_poller_arc); + let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone()); app.manage(session_poller_wrapper); // On Android, set up the MediaSession (lockscreen) handler and the @@ -998,6 +1000,12 @@ pub fn run() { let mut connectivity_monitor = ConnectivityMonitor::new(http_client); connectivity_monitor.set_app_handle(app.handle().clone()); + // Wire the connectivity reporter into the session poller so its + // continuous background polls drive reachability (offline detection + // + recovery) even when the user isn't browsing, then start it. + session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter()); + session_poller_arc.start(); + // Wrap in Arc for sharing with AuthManager let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor)); let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone()); diff --git a/src-tauri/src/session_poller/mod.rs b/src-tauri/src/session_poller/mod.rs index 5c22fc7..34ee48c 100644 --- a/src-tauri/src/session_poller/mod.rs +++ b/src-tauri/src/session_poller/mod.rs @@ -32,6 +32,13 @@ pub struct SessionPollerManager { jellyfin_client: Arc>>, playback_mode_manager: Arc, event_emitter: Arc>>>, + /// Optional connectivity reporter. The session poller is the one piece of + /// server traffic that runs continuously even when the user is idle (not + /// browsing the library), so feeding its poll outcomes into the reporter is + /// what lets the app detect going offline — and, crucially, recover when the + /// server returns — without any user interaction. Repository traffic alone + /// can't do this because it only happens while browsing. + connectivity_reporter: Arc>>, // Polling state is_running: Arc, @@ -52,6 +59,7 @@ impl SessionPollerManager { jellyfin_client, playback_mode_manager, event_emitter: Arc::new(Mutex::new(None)), + connectivity_reporter: 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 @@ -64,6 +72,13 @@ impl SessionPollerManager { *self.event_emitter.lock_safe() = Some(emitter); } + /// Wire the connectivity reporter so each poll outcome updates reachability. + /// A successful poll recovers the app to online instantly; sustained poll + /// failures flip it offline (subject to the reporter's debounce window). + pub fn set_connectivity_reporter(&self, reporter: crate::connectivity::ConnectivityReporter) { + *self.connectivity_reporter.lock_safe() = Some(reporter); + } + /// Start the background polling thread pub fn start(&self) { if self.is_running.swap(true, Ordering::Relaxed) { @@ -77,6 +92,7 @@ impl SessionPollerManager { let client = self.jellyfin_client.clone(); let mode_manager = self.playback_mode_manager.clone(); let emitter = self.event_emitter.clone(); + let connectivity_reporter = self.connectivity_reporter.clone(); let is_running = self.is_running.clone(); let hint = self.current_hint.clone(); let interval_ms = self.current_interval_ms.clone(); @@ -96,18 +112,35 @@ impl SessionPollerManager { debug!("[SessionPoller] Polling with interval: {}ms", new_interval); - // Fetch sessions - let sessions_result = rt.block_on(async { + // Fetch sessions. `had_client` distinguishes "server didn't + // answer" from "no client configured" so we only feed real + // request outcomes into the connectivity reporter. + let (sessions_result, had_client) = rt.block_on(async { let client_opt = client.lock_safe().clone(); match client_opt { - Some(c) => c.get_sessions().await, + Some(c) => (c.get_sessions().await, true), None => { debug!("[SessionPoller] Jellyfin client not configured, skipping poll"); - Ok(Vec::new()) + (Ok(Vec::new()), false) } } }); + // Drive the connectivity reporter from this poll's outcome. This + // is what recovers the app to online when the server returns + // while the user is idle, and detects going offline when no + // library browsing is happening. See connectivity/mod.rs. + if had_client { + if let Some(reporter) = connectivity_reporter.lock_safe().clone() { + rt.block_on(async { + match &sessions_result { + Ok(_) => reporter.report_success().await, + Err(e) => reporter.report_network_failure(Some(e.clone())).await, + } + }); + } + } + // Emit event if successful match sessions_result { Ok(sessions) => {