Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-library/src/runtime.rs
T
cucadmuh 11974e1438 feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)
* feat(analysis): align index settings and per-server strategies

Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.

* feat(analysis): add progress tracking and server analysis deletion functionality

Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.

* feat(server): implement server index key migration and enhance server ID resolution

Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.

* refactor(library): simplify server ID handling in sync progress and idle subscriptions

Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.

* refactor(analytics): rename advanced strategy to aggressive and update descriptions

Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.

* fix(audio): update server ID handling in audio progress functions

Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.

* refactor(analysis): update server ID handling and drop legacy keys

Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.

* refactor(migration): switch to strategy C dual-db flow

Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.

* fix(migration): harden runtime db switch and startup gate

Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.

* feat(migration): enhance migration reporting with skipped server rows tracking

Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.

* fix(migration): avoid startup blocking modal on no-op runs

Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.

* fix(migration): enforce startup precheck and purge unknown rows

Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.

* fix(migration): block UI during done-flag precheck

Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.

* fix(migration): hide precheck modal when no migration is needed

Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.

* fix(migration): cleanup legacy db files after path migration

Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.

* docs(changelog): add PR #864 release notes and contributor credit

Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.

* test(analysis): raise hot-path coverage for analysis cache

Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.

* fix(migration): make rebind pass resilient to foreign key ordering

Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.

* feat(backup): add dual-database backup flow and blocking UX

Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.

* docs(changelog): add PR #864 backup notes and contributor credit

Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.

* docs(changelog): sort 1.47.0 entries from old to new

Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.

* fix(playback): align offline/hot cache lookup with indexKey scope

Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
2026-05-24 21:11:04 +03:00

336 lines
12 KiB
Rust

//! `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<SyncSupervisor>` 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<String>,
pub library_scope: Option<String>,
}
/// 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<AtomicBool>,
/// Signaled when this job's runner task finishes (success, error, or cancel).
pub done: Arc<Notify>,
}
pub struct LibraryRuntime {
pub store: Arc<LibraryStore>,
/// 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<HashMap<String, SyncSession>>,
pub playback_hint: Mutex<PlaybackHint>,
/// 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<Option<CurrentJob>>,
/// Top-crate scheduler tick task watches this flag; set true on
/// app shutdown / library index disabled.
pub scheduler_cancel: Arc<AtomicBool>,
/// 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<HashMap<String, AnalysisProgressCacheEntry>>,
}
impl LibraryRuntime {
pub fn new(store: Arc<LibraryStore>) -> 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<CurrentJob> {
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<SyncSession> {
self.sync_sessions
.lock()
.map(|sessions| sessions.values().cloned().collect())
.unwrap_or_default()
}
pub fn get_session(&self, server_id: &str) -> Option<SyncSession> {
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<AnalysisProgressCacheEntry> {
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"
);
}
}