//! `LibraryRuntime` — Tauri State shared by every library command. //! //! PR-5a held only the store. PR-5b extends with the per-server sync //! session map (credentials live in process memory only — same trust //! boundary as today's WebView-held passwords), the current playback //! hint, an `Option` for in-flight start/cancel, and //! a long-lived cancellation flag for the background-scheduler task //! the top crate spawns in `setup()`. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::sync::Notify; use crate::analysis_backfill::LibraryAnalysisProgressDto; use crate::store::LibraryStore; use crate::sync::bandwidth::PlaybackHint; #[derive(Debug, Clone)] pub struct AnalysisProgressCacheEntry { pub value: LibraryAnalysisProgressDto, pub updated_at: Instant, pub in_flight: bool, } /// Per-server credentials cache for the sync runner. Lives only in /// `LibraryRuntime` process memory; `library_sync_clear_session` /// removes it on logout / index disable / purge. #[derive(Debug, Clone)] pub struct SyncSession { pub server_id: String, pub base_url: String, pub username: String, pub password: String, /// Navidrome native API bearer cached from the `/auth/login` /// response at bind time. `None` when the server isn't Navidrome /// or the optional Navidrome auth failed (Subsonic-only path). pub navidrome_token: Option, pub library_scope: Option, } /// Currently-running initial / delta / manual integrity job /// metadata. Holding the `SyncSupervisor` in the mutex (as the /// PR-5 kickoff sketch suggested) would block `library_sync_cancel` /// behind whoever's running the supervisor's join — instead we keep /// just the cancel handle + identity, and the job-orchestrator task /// owns the supervisor / receiver / join. #[derive(Debug, Clone)] pub struct CurrentJob { pub job_id: String, pub server_id: String, /// `"initial_sync"` or `"delta_sync"`. pub kind: String, pub cancel: Arc, /// Signaled when this job's runner task finishes (success, error, or cancel). pub done: Arc, } pub struct LibraryRuntime { pub store: Arc, /// Per-`server_id` sync session. Mutex over a `HashMap` — single /// writer at a time is fine for the command surface; the /// background scheduler tick reads a snapshot. pub sync_sessions: Mutex>, pub playback_hint: Mutex, /// Currently running initial / delta / manual integrity job, if /// any. `library_sync_start` populates, `library_sync_cancel` /// trips `cancel`; the orchestrator task clears the slot when /// the job's `join` returns. pub current_job: Mutex>, /// Top-crate scheduler tick task watches this flag; set true on /// app shutdown / library index disabled. pub scheduler_cancel: Arc, /// Latest `library_live_search` epoch from the UI — stale commands /// skip FTS when a newer keystroke generation was registered. live_search_epoch: AtomicU64, /// Cached analysis progress snapshots keyed by server id. analysis_progress_cache: Mutex>, } impl LibraryRuntime { pub fn new(store: Arc) -> Self { Self { store, sync_sessions: Mutex::new(HashMap::new()), playback_hint: Mutex::new(PlaybackHint::default()), current_job: Mutex::new(None), scheduler_cancel: Arc::new(AtomicBool::new(false)), live_search_epoch: AtomicU64::new(0), analysis_progress_cache: Mutex::new(HashMap::new()), } } /// UI bumps `epoch` on every debounced search start / cancel. pub fn register_live_search_epoch(&self, epoch: u64) { let _ = self.live_search_epoch.fetch_max(epoch, Ordering::SeqCst); } pub fn live_search_still_current(&self, epoch: u64) -> bool { self.live_search_epoch.load(Ordering::Acquire) == epoch } pub fn set_current_job(&self, job: CurrentJob) { if let Ok(mut slot) = self.current_job.lock() { // Best-effort cancel any in-flight job before we replace. if let Some(prev) = slot.as_ref() { prev.cancel.store(true, std::sync::atomic::Ordering::SeqCst); } *slot = Some(job); } } pub fn current_job(&self) -> Option { self.current_job.lock().ok().and_then(|s| s.clone()) } pub fn clear_current_job_if_matches(&self, job_id: &str) { if let Ok(mut slot) = self.current_job.lock() { if slot.as_ref().is_some_and(|j| j.job_id == job_id) { *slot = None; } } } pub fn cancel_current_job(&self) -> bool { if let Ok(slot) = self.current_job.lock() { if let Some(job) = slot.as_ref() { job.cancel.store(true, std::sync::atomic::Ordering::SeqCst); return true; } } false } /// Snapshot all bound sessions — used by the scheduler tick task /// in the top crate so it doesn't hold the mutex across an `await`. pub fn snapshot_sessions(&self) -> Vec { self.sync_sessions .lock() .map(|sessions| sessions.values().cloned().collect()) .unwrap_or_default() } pub fn get_session(&self, server_id: &str) -> Option { self.sync_sessions .lock() .ok() .and_then(|s| s.get(server_id).cloned()) } pub fn set_session(&self, session: SyncSession) { if let Ok(mut sessions) = self.sync_sessions.lock() { sessions.insert(session.server_id.clone(), session); } } pub fn clear_session(&self, server_id: &str) { if let Ok(mut sessions) = self.sync_sessions.lock() { sessions.remove(server_id); } } pub fn current_playback_hint(&self) -> PlaybackHint { self.playback_hint .lock() .map(|h| *h) .unwrap_or_default() } pub fn set_playback_hint(&self, hint: PlaybackHint) { if let Ok(mut h) = self.playback_hint.lock() { *h = hint; } } pub fn analysis_progress_snapshot( &self, server_id: &str, ) -> Option { self.analysis_progress_cache .lock() .ok() .and_then(|cache| cache.get(server_id).cloned()) } pub fn mark_analysis_progress_in_flight(&self, server_id: &str) -> bool { if let Ok(mut cache) = self.analysis_progress_cache.lock() { match cache.get_mut(server_id) { Some(entry) => { if entry.in_flight { return false; } entry.in_flight = true; return true; } None => { cache.insert( server_id.to_string(), AnalysisProgressCacheEntry { value: LibraryAnalysisProgressDto { total_tracks: 0, pending_tracks: 0, done_tracks: 0, }, updated_at: Instant::now() - Duration::from_secs(60), in_flight: true, }, ); return true; } } } false } pub fn set_analysis_progress( &self, server_id: &str, value: LibraryAnalysisProgressDto, ) { if let Ok(mut cache) = self.analysis_progress_cache.lock() { cache.insert( server_id.to_string(), AnalysisProgressCacheEntry { value, updated_at: Instant::now(), in_flight: false, }, ); } } pub fn clear_analysis_progress_in_flight(&self, server_id: &str) { if let Ok(mut cache) = self.analysis_progress_cache.lock() { if let Some(entry) = cache.get_mut(server_id) { entry.in_flight = false; } } } } #[cfg(test)] mod tests { use super::*; fn sample_session(server_id: &str) -> SyncSession { SyncSession { server_id: server_id.into(), base_url: "https://nas.example.com".into(), username: "u".into(), password: "p".into(), navidrome_token: None, library_scope: None, } } #[test] fn new_runtime_has_empty_sessions_and_idle_hint() { let store = Arc::new(LibraryStore::open_in_memory()); let rt = LibraryRuntime::new(store); assert!(rt.snapshot_sessions().is_empty()); assert_eq!(rt.current_playback_hint(), PlaybackHint::Idle); assert!(!rt .scheduler_cancel .load(std::sync::atomic::Ordering::SeqCst)); } #[test] fn set_and_get_session_roundtrip() { let store = Arc::new(LibraryStore::open_in_memory()); let rt = LibraryRuntime::new(store); rt.set_session(sample_session("s1")); let got = rt.get_session("s1").unwrap(); assert_eq!(got.base_url, "https://nas.example.com"); assert_eq!(got.username, "u"); } #[test] fn clear_session_removes_one_server_only() { let store = Arc::new(LibraryStore::open_in_memory()); let rt = LibraryRuntime::new(store); rt.set_session(sample_session("s1")); rt.set_session(sample_session("s2")); rt.clear_session("s1"); assert!(rt.get_session("s1").is_none()); assert!(rt.get_session("s2").is_some()); } #[test] fn snapshot_returns_clones_so_lock_drops_after_call() { let store = Arc::new(LibraryStore::open_in_memory()); let rt = LibraryRuntime::new(store); rt.set_session(sample_session("s1")); let snap = rt.snapshot_sessions(); // Should be free to mutate after the snapshot. rt.set_session(sample_session("s2")); assert_eq!(snap.len(), 1); assert_eq!(rt.snapshot_sessions().len(), 2); } #[test] fn playback_hint_default_is_idle_and_setter_updates() { let store = Arc::new(LibraryStore::open_in_memory()); let rt = LibraryRuntime::new(store); assert_eq!(rt.current_playback_hint(), PlaybackHint::Idle); rt.set_playback_hint(PlaybackHint::Playing); assert_eq!(rt.current_playback_hint(), PlaybackHint::Playing); rt.set_playback_hint(PlaybackHint::PrefetchActive); assert_eq!(rt.current_playback_hint(), PlaybackHint::PrefetchActive); } #[tokio::test] async fn job_done_notify_one_survives_early_signal_before_await() { let done = Arc::new(Notify::new()); done.notify_one(); tokio::time::timeout(std::time::Duration::from_millis(50), done.notified()) .await .expect("notify_one must store a permit for a later waiter"); } #[tokio::test] async fn job_done_notify_waiters_loses_early_signal_before_await() { let done = Arc::new(Notify::new()); done.notify_waiters(); let waited = tokio::time::timeout(std::time::Duration::from_millis(20), done.notified()) .await .is_ok(); assert!( !waited, "notify_waiters must not store a permit — resync drain uses notify_one instead" ); } }