- Add #[specta::specta] to all 201 #[tauri::command] functions. - Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/ download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState, ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.). - Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands! in lib.rs (exports bindings.ts in debug builds). Two contract changes required by specta constraints (frontend migration follows): - specta caps command arity at 10 args: download_item_and_start / download_item / download_video now take a single request struct (params bundled, body unchanged via destructuring). - specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these now serialize PascalCase to the frontend). cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
371 lines
13 KiB
Rust
371 lines
13 KiB
Rust
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::RwLock;
|
|
use tauri::{AppHandle, Emitter};
|
|
use serde::{Serialize, Deserialize};
|
|
|
|
use crate::jellyfin::http_client::HttpClient;
|
|
|
|
// Adaptive polling intervals (matches TypeScript)
|
|
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online
|
|
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
|
|
|
|
/// 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<String>,
|
|
/// Error message from last connectivity check
|
|
pub connection_error: Option<String>,
|
|
/// 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,
|
|
}
|
|
|
|
/// Connectivity monitor for tracking server reachability
|
|
pub struct ConnectivityMonitor {
|
|
server_url: Arc<RwLock<Option<String>>>,
|
|
http_client: Arc<HttpClient>,
|
|
status: Arc<RwLock<ConnectivityStatus>>,
|
|
is_monitoring: Arc<AtomicBool>,
|
|
app_handle: Option<AppHandle>,
|
|
}
|
|
|
|
impl ConnectivityMonitor {
|
|
/// Create a new connectivity monitor
|
|
pub fn new(http_client: HttpClient) -> Self {
|
|
Self {
|
|
server_url: Arc::new(RwLock::new(None)),
|
|
http_client: Arc::new(http_client),
|
|
status: Arc::new(RwLock::new(ConnectivityStatus::default())),
|
|
is_monitoring: Arc::new(AtomicBool::new(false)),
|
|
app_handle: None,
|
|
}
|
|
}
|
|
|
|
/// Set the Tauri app handle for event emission
|
|
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
|
|
self.app_handle = Some(app_handle);
|
|
}
|
|
|
|
/// Update the server URL
|
|
pub async fn set_server_url(&self, url: String) {
|
|
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
|
|
let mut server_url = self.server_url.write().await;
|
|
*server_url = Some(url.clone());
|
|
drop(server_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.status.read().await.clone()
|
|
}
|
|
|
|
/// Check if the Jellyfin server is reachable
|
|
pub async fn check_reachability(&self) -> bool {
|
|
// Mark as checking
|
|
{
|
|
let mut status = self.status.write().await;
|
|
status.is_checking = true;
|
|
}
|
|
|
|
let server_url = self.server_url.read().await.clone();
|
|
|
|
if server_url.is_none() {
|
|
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
|
|
let mut status = self.status.write().await;
|
|
status.is_server_reachable = false;
|
|
status.connection_error = Some("No server URL configured".to_string());
|
|
status.is_checking = false;
|
|
return false;
|
|
}
|
|
|
|
let url = server_url.unwrap();
|
|
let ping_url = format!("{}/System/Info/Public", url);
|
|
|
|
// Store previous reachability state
|
|
let was_reachable = {
|
|
let status = self.status.read().await;
|
|
status.is_server_reachable
|
|
};
|
|
|
|
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
|
|
|
|
// Attempt to ping the server
|
|
let is_reachable = self.http_client.ping(&ping_url).await;
|
|
|
|
log::debug!(
|
|
"[ConnectivityMonitor] Ping result: {} (was: {})",
|
|
if is_reachable { "SUCCESS" } else { "FAILED" },
|
|
if was_reachable { "reachable" } else { "unreachable" }
|
|
);
|
|
|
|
// Update status
|
|
{
|
|
let mut status = self.status.write().await;
|
|
status.is_server_reachable = is_reachable;
|
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
|
status.connection_error = if is_reachable {
|
|
None
|
|
} else {
|
|
Some("Server unreachable".to_string())
|
|
};
|
|
status.is_checking = false;
|
|
}
|
|
|
|
// Emit events if reachability changed
|
|
if is_reachable != was_reachable {
|
|
self.emit_connectivity_change(is_reachable).await;
|
|
}
|
|
|
|
// Emit reconnection event
|
|
if is_reachable && !was_reachable {
|
|
self.emit_server_reconnected().await;
|
|
}
|
|
|
|
is_reachable
|
|
}
|
|
|
|
/// Mark server as reachable (called after successful API call)
|
|
pub async fn mark_reachable(&self) {
|
|
let mut status = self.status.write().await;
|
|
let was_reachable = status.is_server_reachable;
|
|
|
|
status.is_server_reachable = true;
|
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
|
status.connection_error = None;
|
|
|
|
drop(status);
|
|
|
|
if !was_reachable {
|
|
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
|
|
self.emit_connectivity_change(true).await;
|
|
self.emit_server_reconnected().await;
|
|
}
|
|
}
|
|
|
|
/// Mark server as unreachable (called after failed API call)
|
|
pub async fn mark_unreachable(&self, error: Option<String>) {
|
|
let mut status = self.status.write().await;
|
|
let was_reachable = status.is_server_reachable;
|
|
|
|
status.is_server_reachable = false;
|
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
|
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
|
|
|
|
let error_msg = status.connection_error.clone().unwrap_or_default();
|
|
drop(status);
|
|
|
|
if was_reachable {
|
|
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
|
|
self.emit_connectivity_change(false).await;
|
|
}
|
|
}
|
|
|
|
/// Start monitoring connectivity with adaptive polling
|
|
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");
|
|
|
|
// Perform immediate check before starting background task
|
|
// This ensures we get an accurate state right away instead of assuming offline
|
|
let is_reachable = self.check_reachability().await;
|
|
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
|
|
|
|
// Clone Arc references for the background task
|
|
let status = Arc::clone(&self.status);
|
|
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 self_clone = Arc::new(ConnectivityMonitorHandle {
|
|
server_url,
|
|
http_client,
|
|
status,
|
|
app_handle: self.app_handle.clone(),
|
|
});
|
|
|
|
// Spawn background monitoring task
|
|
tokio::spawn(async move {
|
|
while is_monitoring.load(Ordering::SeqCst) {
|
|
// Determine interval based on current reachability
|
|
let interval_ms = {
|
|
let status = self_clone.status.read().await;
|
|
if status.is_server_reachable {
|
|
AUTO_CHECK_INTERVAL_MS
|
|
} else {
|
|
RETRY_CHECK_INTERVAL_MS
|
|
}
|
|
};
|
|
|
|
// Wait for the interval
|
|
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
|
|
|
|
// Check if still monitoring
|
|
if !is_monitoring.load(Ordering::SeqCst) {
|
|
break;
|
|
}
|
|
|
|
// Perform connectivity check
|
|
let _ = self_clone.check_reachability().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);
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle for the background monitoring task
|
|
struct ConnectivityMonitorHandle {
|
|
server_url: Arc<RwLock<Option<String>>>,
|
|
http_client: Arc<HttpClient>,
|
|
status: Arc<RwLock<ConnectivityStatus>>,
|
|
app_handle: Option<AppHandle>,
|
|
}
|
|
|
|
impl ConnectivityMonitorHandle {
|
|
async fn check_reachability(&self) -> bool {
|
|
let server_url = self.server_url.read().await.clone();
|
|
|
|
if server_url.is_none() {
|
|
return false;
|
|
}
|
|
|
|
let url = server_url.unwrap();
|
|
let ping_url = format!("{}/System/Info/Public", url);
|
|
|
|
// Store previous reachability state
|
|
let was_reachable = {
|
|
let status = self.status.read().await;
|
|
status.is_server_reachable
|
|
};
|
|
|
|
// Attempt to ping the server
|
|
let is_reachable = self.http_client.ping(&ping_url).await;
|
|
|
|
// Update status
|
|
{
|
|
let mut status = self.status.write().await;
|
|
status.is_server_reachable = is_reachable;
|
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
|
status.connection_error = if is_reachable {
|
|
None
|
|
} else {
|
|
Some("Server unreachable".to_string())
|
|
};
|
|
}
|
|
|
|
// Emit events if reachability changed
|
|
if is_reachable != was_reachable {
|
|
self.emit_connectivity_change(is_reachable).await;
|
|
}
|
|
|
|
// Emit reconnection event
|
|
if is_reachable && !was_reachable {
|
|
self.emit_server_reconnected().await;
|
|
}
|
|
|
|
is_reachable
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::jellyfin::http_client::HttpConfig;
|
|
|
|
#[test]
|
|
fn test_intervals() {
|
|
// Verify intervals match TypeScript
|
|
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
|
|
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|