jellytau_lib/connectivity/
mod.rs1use serde::{Deserialize, Serialize};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5use tauri::{AppHandle, Emitter};
6use tokio::sync::RwLock;
7
8use crate::jellyfin::http_client::HttpClient;
9
10const RETRY_CHECK_INTERVAL_MS: u64 = 5000; const OFFLINE_CONFIRM_WINDOW: Duration = Duration::from_secs(5);
21
22#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ConnectivityStatus {
26 pub is_server_reachable: bool,
28 pub last_checked: Option<String>,
30 pub connection_error: Option<String>,
32 pub is_checking: bool,
34}
35
36impl Default for ConnectivityStatus {
37 fn default() -> Self {
38 Self {
39 is_server_reachable: true,
42 last_checked: None,
43 connection_error: None,
44 is_checking: false,
45 }
46 }
47}
48
49#[derive(specta::Type, Debug, Clone, Serialize)]
51#[serde(rename_all = "camelCase")]
52struct ConnectivityChangeEvent {
53 is_reachable: bool,
54}
55
56#[derive(Clone)]
66pub struct ConnectivityReporter {
67 status: Arc<RwLock<ConnectivityStatus>>,
68 first_failure_at: Arc<RwLock<Option<Instant>>>,
71 app_handle: Option<AppHandle>,
72}
73
74impl ConnectivityReporter {
75 fn new(status: Arc<RwLock<ConnectivityStatus>>, app_handle: Option<AppHandle>) -> Self {
76 Self {
77 status,
78 first_failure_at: Arc::new(RwLock::new(None)),
79 app_handle,
80 }
81 }
82
83 #[allow(dead_code)] pub async fn is_reachable(&self) -> bool {
87 self.status.read().await.is_server_reachable
88 }
89
90 #[cfg(test)]
93 pub async fn mark_unreachable_for_test(&self) {
94 self.apply_probe_result(false, Some("forced offline (test)".to_string()))
95 .await;
96 }
97
98 pub async fn report_success(&self) {
101 *self.first_failure_at.write().await = None;
102 self.set_reachable(true, None).await;
103 }
104
105 pub async fn report_network_failure(&self, error: Option<String>) {
110 if !self.status.read().await.is_server_reachable {
112 return;
113 }
114
115 let now = Instant::now();
116 let streak_start = {
117 let mut first = self.first_failure_at.write().await;
118 *first.get_or_insert(now)
119 };
120
121 if now.duration_since(streak_start) >= OFFLINE_CONFIRM_WINDOW {
122 log::warn!(
123 "[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline",
124 OFFLINE_CONFIRM_WINDOW
125 );
126 self.set_reachable(false, error).await;
127 } else {
128 log::debug!(
129 "[ConnectivityMonitor] Network failure within debounce window; not yet offline"
130 );
131 }
132 }
133
134 async fn apply_probe_result(&self, is_reachable: bool, error: Option<String>) {
138 if is_reachable {
139 *self.first_failure_at.write().await = None;
140 }
141 self.set_reachable(is_reachable, error).await;
142 }
143
144 async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
146 let was_reachable = {
147 let mut status = self.status.write().await;
148 let was = status.is_server_reachable;
149 status.is_server_reachable = is_reachable;
150 status.last_checked = Some(chrono::Utc::now().to_rfc3339());
151 status.connection_error = if is_reachable {
152 None
153 } else {
154 Some(error.unwrap_or_else(|| "Server unreachable".to_string()))
155 };
156 status.is_checking = false;
157 was
158 };
159
160 if is_reachable != was_reachable {
161 self.emit_connectivity_change(is_reachable).await;
162 if is_reachable {
163 self.emit_server_reconnected().await;
164 }
165 }
166 }
167
168 async fn emit_connectivity_change(&self, is_reachable: bool) {
170 if let Some(app_handle) = &self.app_handle {
171 let event = ConnectivityChangeEvent { is_reachable };
172 if let Err(e) = app_handle.emit("connectivity:changed", event) {
173 log::error!(
174 "[ConnectivityMonitor] Failed to emit connectivity change event: {}",
175 e
176 );
177 } else {
178 log::info!(
179 "[ConnectivityMonitor] Emitted connectivity change: {}",
180 is_reachable
181 );
182 }
183 }
184 }
185
186 async fn emit_server_reconnected(&self) {
188 if let Some(app_handle) = &self.app_handle {
189 if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
190 log::error!(
191 "[ConnectivityMonitor] Failed to emit reconnection event: {}",
192 e
193 );
194 } else {
195 log::info!("[ConnectivityMonitor] Emitted server reconnected event");
196 }
197 }
198 }
199}
200
201pub struct ConnectivityMonitor {
207 server_url: Arc<RwLock<Option<String>>>,
208 http_client: Arc<HttpClient>,
209 reporter: ConnectivityReporter,
210 is_monitoring: Arc<AtomicBool>,
211}
212
213impl ConnectivityMonitor {
214 pub fn new(http_client: HttpClient) -> Self {
216 let status = Arc::new(RwLock::new(ConnectivityStatus::default()));
217 Self {
218 server_url: Arc::new(RwLock::new(None)),
219 http_client: Arc::new(http_client),
220 reporter: ConnectivityReporter::new(status, None),
221 is_monitoring: Arc::new(AtomicBool::new(false)),
222 }
223 }
224
225 pub fn set_app_handle(&mut self, app_handle: AppHandle) {
228 self.reporter.app_handle = Some(app_handle);
229 }
230
231 pub fn reporter(&self) -> ConnectivityReporter {
234 self.reporter.clone()
235 }
236
237 pub async fn set_server_url(&self, url: String) {
239 log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
240 *self.server_url.write().await = Some(url);
241
242 log::info!("[ConnectivityMonitor] Checking reachability of new server...");
244 let is_reachable = self.check_reachability().await;
245 log::info!(
246 "[ConnectivityMonitor] New server is {}",
247 if is_reachable {
248 "REACHABLE"
249 } else {
250 "UNREACHABLE"
251 }
252 );
253 }
254
255 pub async fn get_status(&self) -> ConnectivityStatus {
257 self.reporter.status.read().await.clone()
258 }
259
260 pub async fn check_reachability(&self) -> bool {
263 {
264 let mut status = self.reporter.status.write().await;
265 status.is_checking = true;
266 }
267
268 let server_url = self.server_url.read().await.clone();
269
270 let Some(url) = server_url else {
271 log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
272 self.reporter
273 .apply_probe_result(false, Some("No server URL configured".to_string()))
274 .await;
275 return false;
276 };
277
278 let ping_url = format!("{}/System/Info/Public", url);
279 log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
280
281 let is_reachable = self.http_client.ping(&ping_url).await;
282 log::debug!(
283 "[ConnectivityMonitor] Ping result: {}",
284 if is_reachable { "SUCCESS" } else { "FAILED" }
285 );
286
287 self.reporter.apply_probe_result(is_reachable, None).await;
288 is_reachable
289 }
290
291 pub async fn mark_reachable(&self) {
293 self.reporter.report_success().await;
294 }
295
296 pub async fn mark_unreachable(&self, error: Option<String>) {
302 self.reporter.apply_probe_result(false, error).await;
303 }
304
305 pub async fn start_monitoring(&self) {
312 if self.is_monitoring.swap(true, Ordering::SeqCst) {
313 log::info!("[ConnectivityMonitor] Already monitoring");
314 return;
315 }
316
317 log::info!(
318 "[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)"
319 );
320
321 let is_reachable = self.check_reachability().await;
323 log::info!(
324 "[ConnectivityMonitor] Initial connectivity check: {}",
325 if is_reachable { "ONLINE" } else { "OFFLINE" }
326 );
327
328 let is_monitoring = Arc::clone(&self.is_monitoring);
329 let server_url = Arc::clone(&self.server_url);
330 let http_client = Arc::clone(&self.http_client);
331 let reporter = self.reporter.clone();
332
333 tokio::spawn(async move {
334 while is_monitoring.load(Ordering::SeqCst) {
335 tokio::time::sleep(Duration::from_millis(RETRY_CHECK_INTERVAL_MS)).await;
336
337 if !is_monitoring.load(Ordering::SeqCst) {
338 break;
339 }
340
341 if reporter.status.read().await.is_server_reachable {
343 continue;
344 }
345
346 let Some(url) = server_url.read().await.clone() else {
347 continue;
348 };
349 let ping_url = format!("{}/System/Info/Public", url);
350 let is_reachable = http_client.ping(&ping_url).await;
351
352 if is_reachable {
355 reporter.apply_probe_result(true, None).await;
356 }
357 }
358
359 log::info!("[ConnectivityMonitor] Stopped monitoring");
360 });
361 }
362
363 pub fn stop_monitoring(&self) {
365 log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
366 self.is_monitoring.store(false, Ordering::SeqCst);
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn test_reporter() -> ConnectivityReporter {
378 ConnectivityReporter::new(Arc::new(RwLock::new(ConnectivityStatus::default())), None)
379 }
380
381 async fn is_reachable(reporter: &ConnectivityReporter) -> bool {
382 reporter.status.read().await.is_server_reachable
383 }
384
385 #[test]
386 fn test_intervals() {
387 assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
389 assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5));
390 }
391
392 #[tokio::test]
393 async fn test_default_status() {
394 let status = ConnectivityStatus::default();
395 assert!(status.is_server_reachable);
397 assert!(status.last_checked.is_none());
398 assert!(status.connection_error.is_none());
399 assert!(!status.is_checking);
400 }
401
402 #[tokio::test]
407 async fn test_single_network_failure_does_not_go_offline() {
408 let reporter = test_reporter();
409 assert!(is_reachable(&reporter).await, "starts online");
410
411 reporter
412 .report_network_failure(Some("timeout".to_string()))
413 .await;
414
415 assert!(
416 is_reachable(&reporter).await,
417 "one network failure within the debounce window stays online"
418 );
419 assert!(reporter.first_failure_at.read().await.is_some());
421 }
422
423 #[tokio::test]
428 async fn test_sustained_network_failure_goes_offline() {
429 let reporter = test_reporter();
430
431 reporter.report_network_failure(None).await;
433 assert!(is_reachable(&reporter).await);
434
435 {
437 let mut first = reporter.first_failure_at.write().await;
438 *first = Some(Instant::now() - OFFLINE_CONFIRM_WINDOW - Duration::from_secs(1));
439 }
440
441 reporter
443 .report_network_failure(Some("connection refused".to_string()))
444 .await;
445 assert!(
446 !is_reachable(&reporter).await,
447 "sustained network failure flips to offline"
448 );
449 }
450
451 #[tokio::test]
454 async fn test_success_clears_failure_streak() {
455 let reporter = test_reporter();
456
457 reporter.report_network_failure(None).await;
458 assert!(reporter.first_failure_at.read().await.is_some());
459
460 reporter.report_success().await;
461
462 assert!(is_reachable(&reporter).await);
463 assert!(
464 reporter.first_failure_at.read().await.is_none(),
465 "success resets the debounce streak"
466 );
467 }
468
469 #[tokio::test]
472 async fn test_recovery_is_instant() {
473 let reporter = test_reporter();
474
475 reporter
477 .apply_probe_result(false, Some("down".to_string()))
478 .await;
479 assert!(!is_reachable(&reporter).await);
480
481 reporter.report_success().await;
483 assert!(is_reachable(&reporter).await);
484 let status = reporter.status.read().await;
485 assert!(status.connection_error.is_none());
486 }
487
488 #[tokio::test]
492 async fn test_server_answered_error_counts_as_reachable() {
493 let reporter = test_reporter();
494
495 reporter.apply_probe_result(false, None).await;
497 assert!(!is_reachable(&reporter).await);
498
499 reporter.report_success().await;
501 assert!(
502 is_reachable(&reporter).await,
503 "a server that answers (even with 4xx/5xx) is reachable"
504 );
505 }
506
507 #[tokio::test]
510 async fn test_network_failure_noop_when_already_offline() {
511 let reporter = test_reporter();
512 reporter.apply_probe_result(false, None).await;
513 assert!(!is_reachable(&reporter).await);
514
515 reporter
517 .report_network_failure(Some("still down".to_string()))
518 .await;
519 assert!(!is_reachable(&reporter).await);
520 }
521}