diff --git a/CHANGELOG.md b/CHANGELOG.md index 003a0c11..cdf59871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,14 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.47.0] -### Settings + Queue polish - -**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), adopted from PR [#558](https://github.com/Psychotoxical/psysonic/pull/558), rewritten in PR [#778](https://github.com/Psychotoxical/psysonic/pull/778)** - -* Settings toggle rows dim non-toggle content to 0.6 opacity when their switch is off; mutex-disabled rows (Crossfade/Gapless) unchanged. -* Queue toolbar `Clear` → `Clear queue` across all 9 locales. - - +## Added ### Servers — edit existing profiles @@ -33,14 +26,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Interface Scale — covers the whole window +### Local library index + search (preview) -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#781](https://github.com/Psychotoxical/psysonic/pull/781)** +**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#846](https://github.com/Psychotoxical/psysonic/pull/846)** -* Settings → Appearance → Interface Scale now scales sidebar, queue, player bar, modals/portals and the fullscreen player alongside the main content — same behaviour as browser Ctrl+/−. +* **Settings → Library:** local SQLite track index per server — background initial and delta sync, full resync, integrity verify, and auto-reconcile when the server reports fewer tracks than expected. +* **Live Search** and **Advanced Search** query the local index when it is ready (fast, offline-capable). +* **Multi-server UI** (by [@cucadmuh](https://github.com/cucadmuh)): per-server exclude/include; indexing runs one server at a time so SQLite stays responsive; offline servers are retried automatically. +* Local search results respect the sidebar music-library filter; parallel album fetch during initial sync. +## Changed + ### Linux — session GDK, WebKitGTK mitigations, and Wayland text **By [@cucadmuh](https://github.com/cucadmuh), PR [#731](https://github.com/Psychotoxical/psysonic/pull/731)** @@ -60,20 +58,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Fixed +### Settings + Queue polish + +**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), adopted from PR [#558](https://github.com/Psychotoxical/psysonic/pull/558), rewritten in PR [#778](https://github.com/Psychotoxical/psysonic/pull/778)** + +* Settings toggle rows dim non-toggle content to 0.6 opacity when their switch is off; mutex-disabled rows (Crossfade/Gapless) unchanged. +* Queue toolbar `Clear` → `Clear queue` across all 9 locales. + + + +### Interface Scale — covers the whole window + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#781](https://github.com/Psychotoxical/psysonic/pull/781)** + +* Settings → Appearance → Interface Scale now scales sidebar, queue, player bar, modals/portals and the fullscreen player alongside the main content — same behaviour as browser Ctrl+/−. + + + +### Radio — card control polish + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** + +* Repeat is disabled while a radio stream plays. +* Deleting the playing station fades out instead of cutting hard. +* Play / Stop tooltip on the cover-overlay button; stop uses a Square icon. + + + +## Fixed + +### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** * **In-page browse:** virtual artist/album/composer grids and lists no longer lose all rows after deep scroll — `scrollMargin` now targets the in-page overlay viewport, not the locked main route scrollport. * **Cover art on browse pages:** `CachedImage` priority scoring follows the real scrolling pane so visible thumbnails win network fetch slots; Artists infinite scroll loads one page per batch instead of re-entrantly queueing many pages during a fast fling. + + +### Lucky Mix after server switch + **By [@cucadmuh](https://github.com/cucadmuh), PR [#785](https://github.com/Psychotoxical/psysonic/pull/785)** -* **Lucky Mix after server switch:** starting a mix on the browsed server no longer spams cross-server enqueue errors — unpinned or foreign queues hand off cleanly before batch enqueue. +* Starting a mix on the browsed server no longer spams cross-server enqueue errors — unpinned or foreign queues hand off cleanly before batch enqueue. -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#844](https://github.com/Psychotoxical/psysonic/pull/844)** +### Radio — paused streams stay paused -* **Album view:** bulk "Add to playlist" no longer clears the track selection without opening the playlist picker. +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by drelabre on GitHub, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** + +* Pausing a radio stream no longer auto-resumes after about a minute on macOS. @@ -86,21 +119,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Radio — paused streams stay paused +### Album view — bulk add to playlist selection -**By [@Psychotoxical](https://github.com/Psychotoxical), reported by drelabre on GitHub, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#844](https://github.com/Psychotoxical/psysonic/pull/844)** -* Pausing a radio stream no longer auto-resumes after about a minute on macOS. - - - -### Radio — card control polish - -**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)** - -* Repeat is disabled while a radio stream plays. -* Deleting the playing station fades out instead of cutting hard. -* Play / Stop tooltip on the cover-overlay button; stop uses a Square icon. +* Bulk "Add to playlist" no longer clears the track selection without opening the playlist picker. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 907102f3..b6508c85 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3723,6 +3723,7 @@ dependencies = [ "psysonic-audio", "psysonic-core", "psysonic-integration", + "psysonic-library", "psysonic-syncfs", "reqwest", "ringbuf", @@ -3826,6 +3827,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "psysonic-library" +version = "1.47.0-dev" +dependencies = [ + "psysonic-core", + "psysonic-integration", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tokio", + "wiremock", +] + [[package]] name = "psysonic-syncfs" version = "1.47.0-dev" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 22155821..1977fbc1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ tauri-build = { version = "2", features = [] } psysonic-core = { path = "crates/psysonic-core" } psysonic-analysis = { path = "crates/psysonic-analysis" } psysonic-audio = { path = "crates/psysonic-audio" } +psysonic-library = { path = "crates/psysonic-library" } psysonic-syncfs = { path = "crates/psysonic-syncfs" } psysonic-integration = { path = "crates/psysonic-integration" } tauri = { version = "2", features = ["tray-icon", "image-png"] } diff --git a/src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql b/src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql new file mode 100644 index 00000000..7f5f97f6 --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/migrations/001_baseline.sql @@ -0,0 +1,44 @@ +-- Baseline: the pre-versioning analysis cache schema. +-- +-- This is the exact shape every existing user DB already carries (created by +-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a +-- no-op on those DBs and creates the tables on a fresh one, so "migration 1 +-- applied" means "the schema that shipped before versioned migrations". +-- +-- Server-scoping (server_id) is added additively in 002. + +CREATE TABLE IF NOT EXISTS analysis_track ( + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + status TEXT NOT NULL, + waveform_algo_version INTEGER NOT NULL, + loudness_algo_version INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (track_id, md5_16kb) +); + +CREATE TABLE IF NOT EXISTS waveform_cache ( + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + bins BLOB NOT NULL, + bin_count INTEGER NOT NULL, + is_partial INTEGER NOT NULL, + known_until_sec REAL NOT NULL, + duration_sec REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (track_id, md5_16kb) +); + +CREATE TABLE IF NOT EXISTS loudness_cache ( + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + integrated_lufs REAL NOT NULL, + true_peak REAL NOT NULL, + recommended_gain_db REAL NOT NULL, + target_lufs REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (track_id, md5_16kb, target_lufs) +); + +CREATE INDEX IF NOT EXISTS idx_analysis_track_status + ON analysis_track(status); diff --git a/src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql b/src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql new file mode 100644 index 00000000..90b64b2b --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/migrations/002_server_id.sql @@ -0,0 +1,73 @@ +-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped +-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each +-- table is rebuilt: create the v2 shape, copy every row with server_id = '', +-- drop the old table, rename. Existing rows become legacy ('') rows that the +-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2). +-- +-- Atomicity: the migration runner wraps this whole file plus the +-- schema_migrations marker in one transaction, so any failure or crash rolls +-- everything back to the original tables — DROP never runs unless the copy +-- before it succeeded. No BEGIN/COMMIT here (that would nest). +-- +-- These three tables have no foreign keys between them or from any other table, +-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op +-- inside a transaction anyway). + +-- analysis_track +CREATE TABLE analysis_track_v2 ( + server_id TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + status TEXT NOT NULL, + waveform_algo_version INTEGER NOT NULL, + loudness_algo_version INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, md5_16kb) +); +INSERT INTO analysis_track_v2 + (server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at + FROM analysis_track; +DROP TABLE analysis_track; +ALTER TABLE analysis_track_v2 RENAME TO analysis_track; +CREATE INDEX IF NOT EXISTS idx_analysis_track_status + ON analysis_track(status); + +-- waveform_cache +CREATE TABLE waveform_cache_v2 ( + server_id TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + bins BLOB NOT NULL, + bin_count INTEGER NOT NULL, + is_partial INTEGER NOT NULL, + known_until_sec REAL NOT NULL, + duration_sec REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, md5_16kb) +); +INSERT INTO waveform_cache_v2 + (server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at) + SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at + FROM waveform_cache; +DROP TABLE waveform_cache; +ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache; + +-- loudness_cache +CREATE TABLE loudness_cache_v2 ( + server_id TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL, + md5_16kb TEXT NOT NULL, + integrated_lufs REAL NOT NULL, + true_peak REAL NOT NULL, + recommended_gain_db REAL NOT NULL, + target_lufs REAL NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs) +); +INSERT INTO loudness_cache_v2 + (server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at) + SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at + FROM loudness_cache; +DROP TABLE loudness_cache; +ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache; diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs index 104bf19a..26a8350d 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs @@ -40,6 +40,7 @@ pub enum SeedFromBytesOutcome { /// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs. pub fn seed_from_bytes_execute( app: &tauri::AppHandle, + server_id: &str, track_id: &str, bytes: &[u8], ) -> Result { @@ -51,20 +52,45 @@ pub fn seed_from_bytes_execute( ); return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache); }; - seed_from_bytes_into_cache(&cache, track_id, bytes) + let (outcome, md5_16kb) = seed_from_bytes_into_cache(&cache, server_id, track_id, bytes)?; + // E2 bridge (analysis → library content_hash): once the playback-derived + // md5_16kb is known — whether freshly written or already cached — record it + // as `track.content_hash` via the registered sink. Decoupled from + // psysonic-library through the psysonic-core port; a no-op when the library + // has no row for this (server_id, track_id). Skipped under the legacy '' + // scope (no server known). + if !server_id.is_empty() + && matches!( + outcome, + SeedFromBytesOutcome::Upserted | SeedFromBytesOutcome::SkippedWaveformCacheHit + ) + { + if let Some(sink) = app.try_state::() { + sink.record_content_hash(server_id, track_id, &md5_16kb); + } + } + Ok(outcome) } /// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache /// directly, runs the same Symphonia → waveform → EBU R128 pipeline, and /// upserts the rows. Called from `seed_from_bytes_execute` in production and /// from tests against an in-memory cache. +/// Returns the outcome plus the computed `md5_16kb` (the content fingerprint), +/// so the AppHandle-aware caller can bridge it to the library `content_hash` +/// (E2) without re-reading the bytes. pub fn seed_from_bytes_into_cache( cache: &AnalysisCache, + server_id: &str, track_id: &str, bytes: &[u8], -) -> Result { +) -> Result<(SeedFromBytesOutcome, String), String> { let started = Instant::now(); + // Write under the playback server's scope. An empty `server_id` (caller did + // not know the server) lands under the legacy '' pool — the read path's + // legacy fallback + lazy re-tag keeps it resolvable. let key = TrackKey { + server_id: server_id.to_string(), track_id: track_id.to_string(), md5_16kb: md5_first_16kb(bytes), }; @@ -78,7 +104,7 @@ pub fn seed_from_bytes_into_cache( existing.bins.len(), started.elapsed().as_millis() ); - return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit); + return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone())); } crate::app_deprintln!( "[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}", @@ -164,7 +190,7 @@ pub fn seed_from_bytes_into_cache( } match build { - Ok(_) => Ok(SeedFromBytesOutcome::Upserted), + Ok(_) => Ok((SeedFromBytesOutcome::Upserted, key.md5_16kb.clone())), Err(e) => Err(e), } } @@ -740,12 +766,14 @@ mod tests { fn seed_from_bytes_into_cache_upserts_waveform_and_loudness_for_wav() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); - let outcome = seed_from_bytes_into_cache(&cache, "wav-track", &wav).unwrap(); + let (outcome, md5) = seed_from_bytes_into_cache(&cache, "", "wav-track", &wav).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); + assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint"); // Both a waveform AND a loudness row must exist after a successful // PCM decode + EBU R128 analysis. let key = TrackKey { + server_id: String::new(), track_id: "wav-track".to_string(), md5_16kb: md5_first_16kb(&wav), }; @@ -755,13 +783,37 @@ mod tests { assert!(cache.loudness_row_exists_for_key(&key).unwrap()); } + #[test] + fn seed_from_bytes_into_cache_writes_under_the_given_server_scope() { + let cache = AnalysisCache::open_in_memory(); + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); + seed_from_bytes_into_cache(&cache, "server-x", "scoped-track", &wav).unwrap(); + + let md5 = md5_first_16kb(&wav); + let scoped = TrackKey { + server_id: "server-x".to_string(), + track_id: "scoped-track".to_string(), + md5_16kb: md5.clone(), + }; + let legacy = TrackKey { + server_id: String::new(), + track_id: "scoped-track".to_string(), + md5_16kb: md5, + }; + assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope"); + assert!( + cache.get_waveform(&legacy).unwrap().is_none(), + "nothing written under the legacy '' scope" + ); + } + #[test] fn seed_from_bytes_into_cache_returns_skipped_on_second_call() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let first = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap(); + let (first, _) = seed_from_bytes_into_cache(&cache, "", "wav-track-2", &wav).unwrap(); assert_eq!(first, SeedFromBytesOutcome::Upserted); - let second = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap(); + let (second, _) = seed_from_bytes_into_cache(&cache, "", "wav-track-2", &wav).unwrap(); assert_eq!( second, SeedFromBytesOutcome::SkippedWaveformCacheHit, @@ -775,10 +827,11 @@ mod tests { // Garbage bytes — Symphonia probe fails, the pipeline falls back to // `derive_waveform_bins` (no loudness row gets cached). let bytes = vec![0xAAu8; 8 * 1024]; - let outcome = seed_from_bytes_into_cache(&cache, "garbage", &bytes).unwrap(); + let (outcome, _) = seed_from_bytes_into_cache(&cache, "", "garbage", &bytes).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); let key = TrackKey { + server_id: String::new(), track_id: "garbage".to_string(), md5_16kb: md5_first_16kb(&bytes), }; diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs index 41a4b1d4..4e80e209 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; @@ -8,6 +8,18 @@ use tauri::Manager; pub(super) const WAVEFORM_ALGO_VERSION: i64 = 4; pub(super) const LOUDNESS_ALGO_VERSION: i64 = 1; +/// Current head of the embedded migrations. Bump for each new +/// `migrations/NNN_*.sql`. +pub const ANALYSIS_DB_SCHEMA_VERSION: i64 = 2; + +const MIGRATION_001_BASELINE: &str = include_str!("../../migrations/001_baseline.sql"); +const MIGRATION_002_SERVER_ID: &str = include_str!("../../migrations/002_server_id.sql"); + +/// Embedded migrations, ascending by version. The runner sorts defensively and +/// applies each missing one in its own transaction (schema change + version +/// marker commit together — see [`run_migrations_with`]). +const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_001_BASELINE), (2, MIGRATION_002_SERVER_ID)]; + /// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin). fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool { if bin_count <= 0 { @@ -19,6 +31,10 @@ fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool { #[derive(Debug, Clone)] pub struct TrackKey { + /// App server id this analysis belongs to. Empty string is the legacy + /// (pre-002) value: rows migrated from the unscoped schema and any caller + /// that does not yet know the server (filled in by 6c-2). + pub server_id: String, pub track_id: String, pub md5_16kb: String, } @@ -84,9 +100,10 @@ impl AnalysisCache { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let conn = Connection::open(db_path).map_err(|e| e.to_string())?; + backup_before_pending_migration(&db_path)?; + let mut conn = Connection::open(&db_path).map_err(|e| e.to_string())?; configure_connection(&conn).map_err(|e| e.to_string())?; - migrate_schema(&conn).map_err(|e| e.to_string())?; + run_migrations(&mut conn).map_err(|e| e.to_string())?; Ok(Self { conn: Mutex::new(conn) }) } @@ -100,14 +117,19 @@ impl AnalysisCache { /// without a `test-support` Cargo feature dance. Production code does not /// use it. pub fn open_in_memory() -> Self { - let conn = Connection::open_in_memory().expect("in-memory connection"); + let mut conn = Connection::open_in_memory().expect("in-memory connection"); conn.pragma_update(None, "foreign_keys", "ON").expect("pragma foreign_keys"); - migrate_schema(&conn).expect("schema migration"); + run_migrations(&mut conn).expect("schema migration"); Self { conn: Mutex::new(conn) } } - /// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant). - pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result { + /// Remove `loudness_cache` rows for this logical track (bare id and `stream:` + /// variant) **scoped to one server plus the legacy `''` pool**. A reseed on + /// server A must not delete server B's analysis for the same bare `track_id`; + /// the legacy `''` rows are cleared too so a stale pre-002 blob can't shadow + /// the fresh re-analysis via the read fallback (and so it isn't seen as + /// redundant). Pass `server_id = ""` to target only the legacy pool. + pub fn delete_loudness_for_track_id(&self, server_id: &str, track_id: &str) -> Result { if track_id.trim().is_empty() { return Ok(0); } @@ -118,15 +140,20 @@ impl AnalysisCache { let mut total: u64 = 0; for tid in track_id_cache_variants(track_id) { let n = conn - .execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid]) + .execute( + "DELETE FROM loudness_cache WHERE track_id = ?1 AND server_id IN (?2, '')", + params![tid, server_id], + ) .map_err(|e| e.to_string())?; total = total.saturating_add(n as u64); } Ok(total) } - /// Remove all `waveform_cache` rows for this logical track (bare id and `stream:` variant). - pub fn delete_waveform_for_track_id(&self, track_id: &str) -> Result { + /// Remove `waveform_cache` rows for this logical track (bare id and `stream:` + /// variant) scoped to one server plus the legacy `''` pool. See + /// [`Self::delete_loudness_for_track_id`] for the scoping rationale. + pub fn delete_waveform_for_track_id(&self, server_id: &str, track_id: &str) -> Result { if track_id.trim().is_empty() { return Ok(0); } @@ -137,7 +164,10 @@ impl AnalysisCache { let mut total: u64 = 0; for tid in track_id_cache_variants(track_id) { let n = conn - .execute("DELETE FROM waveform_cache WHERE track_id = ?1", params![tid]) + .execute( + "DELETE FROM waveform_cache WHERE track_id = ?1 AND server_id IN (?2, '')", + params![tid, server_id], + ) .map_err(|e| e.to_string())?; total = total.saturating_add(n as u64); } @@ -162,15 +192,16 @@ impl AnalysisCache { conn.execute( r#" INSERT INTO analysis_track ( - track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(track_id, md5_16kb) DO UPDATE SET + server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(server_id, track_id, md5_16kb) DO UPDATE SET status = excluded.status, waveform_algo_version = excluded.waveform_algo_version, loudness_algo_version = excluded.loudness_algo_version, updated_at = excluded.updated_at "#, params![ + key.server_id, key.track_id, key.md5_16kb, status, @@ -188,9 +219,9 @@ impl AnalysisCache { conn.execute( r#" INSERT INTO waveform_cache ( - track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(track_id, md5_16kb) DO UPDATE SET + server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(server_id, track_id, md5_16kb) DO UPDATE SET bins = excluded.bins, bin_count = excluded.bin_count, is_partial = excluded.is_partial, @@ -199,6 +230,7 @@ impl AnalysisCache { updated_at = excluded.updated_at "#, params![ + key.server_id, key.track_id, key.md5_16kb, entry.bins, @@ -218,15 +250,16 @@ impl AnalysisCache { conn.execute( r#" INSERT INTO loudness_cache ( - track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET + server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(server_id, track_id, md5_16kb, target_lufs) DO UPDATE SET integrated_lufs = excluded.integrated_lufs, true_peak = excluded.true_peak, recommended_gain_db = excluded.recommended_gain_db, updated_at = excluded.updated_at "#, params![ + key.server_id, key.track_id, key.md5_16kb, entry.integrated_lufs, @@ -248,13 +281,15 @@ impl AnalysisCache { SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at FROM waveform_cache w JOIN analysis_track a - ON a.track_id = w.track_id + ON a.server_id = w.server_id + AND a.track_id = w.track_id AND a.md5_16kb = w.md5_16kb - WHERE w.track_id = ?1 - AND w.md5_16kb = ?2 - AND a.waveform_algo_version = ?3 + WHERE w.server_id = ?1 + AND w.track_id = ?2 + AND w.md5_16kb = ?3 + AND a.waveform_algo_version = ?4 "#, - params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION], + params![key.server_id, key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION], |row| { Ok(WaveformEntry { bins: row.get(0)?, @@ -282,105 +317,214 @@ impl AnalysisCache { SELECT 1 FROM loudness_cache l JOIN analysis_track a - ON a.track_id = l.track_id + ON a.server_id = l.server_id + AND a.track_id = l.track_id AND a.md5_16kb = l.md5_16kb - WHERE l.track_id = ?1 - AND l.md5_16kb = ?2 - AND a.loudness_algo_version = ?3 + WHERE l.server_id = ?1 + AND l.track_id = ?2 + AND l.md5_16kb = ?3 + AND a.loudness_algo_version = ?4 ) "#, - params![key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION], + params![key.server_id, key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION], |row| row.get(0), ) .map_err(|e| e.to_string())?; Ok(exists != 0) } - pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result, String> { + /// Latest waveform for `(server_id, track_id)` with legacy fallback. Tries the + /// server-scoped rows first (both id variants), then the legacy `server_id=''` + /// pool. On a legacy hit while a real `server_id` is known, the matching rows + /// are re-tagged under the server-scoped key (best-effort) so subsequent reads + /// hit the exact key and other servers can't shadow each other via `''`. + pub fn get_latest_waveform_for_track( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; - const SQL: &str = r#" - SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at - FROM waveform_cache w - JOIN analysis_track a - ON a.track_id = w.track_id - AND a.md5_16kb = w.md5_16kb - WHERE w.track_id = ?1 - AND a.waveform_algo_version = ?2 - ORDER BY w.updated_at DESC - LIMIT 1 - "#; - for tid in track_id_cache_variants(track_id) { - let row = conn - .query_row( - SQL, - params![tid, WAVEFORM_ALGO_VERSION], - |row| { - Ok(WaveformEntry { - bins: row.get(0)?, - bin_count: row.get(1)?, - is_partial: row.get::<_, i64>(2)? != 0, - known_until_sec: row.get(3)?, - duration_sec: row.get(4)?, - updated_at: row.get(5)?, - }) - }, - ) - .optional() - .map_err(|e| e.to_string())?; - if let Some(e) = row { - if waveform_cache_blob_len_ok(&e.bins, e.bin_count) { - return Ok(Some(e)); - } + if let Some(e) = query_latest_waveform_scoped(&conn, server_id, track_id)? { + return Ok(Some(e)); + } + if !server_id.is_empty() { + if let Some(e) = query_latest_waveform_scoped(&conn, "", track_id)? { + let _ = relabel_legacy_to_server(&conn, server_id, track_id); + return Ok(Some(e)); } } Ok(None) } - /// Both waveform and loudness rows exist — a CPU seed from bytes/file would only - /// decode the file to immediately skip with `SkippedWaveformCacheHit`. - pub fn cpu_seed_redundant_for_track(&self, track_id: &str) -> Result { + /// Both waveform and loudness rows exist for this `(server_id, track_id)` + /// (including the legacy `''` fallback) — a CPU seed from bytes/file would + /// only decode the file to immediately skip with `SkippedWaveformCacheHit`. + /// A legacy hit is re-tagged onto the server scope as a side effect (see + /// [`Self::get_latest_waveform_for_track`]), so skipping the seed still leaves + /// the track resolvable under its real `server_id`. + pub fn cpu_seed_redundant_for_track(&self, server_id: &str, track_id: &str) -> Result { Ok( - self.get_latest_waveform_for_track(track_id)?.is_some() - && self.get_latest_loudness_for_track(track_id)?.is_some(), + self.get_latest_waveform_for_track(server_id, track_id)?.is_some() + && self.get_latest_loudness_for_track(server_id, track_id)?.is_some(), ) } - pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result, String> { + /// Latest loudness for `(server_id, track_id)` with the same legacy fallback + + /// lazy re-tag behaviour as [`Self::get_latest_waveform_for_track`]. + pub fn get_latest_loudness_for_track( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; - const SQL: &str = r#" - SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at - FROM loudness_cache l - JOIN analysis_track a - ON a.track_id = l.track_id - AND a.md5_16kb = l.md5_16kb - WHERE l.track_id = ?1 - AND a.loudness_algo_version = ?2 - ORDER BY l.updated_at DESC - LIMIT 1 - "#; - for tid in track_id_cache_variants(track_id) { - let row = conn - .query_row( - SQL, - params![tid, LOUDNESS_ALGO_VERSION], - |row| { - Ok(LoudnessSnapshot { - integrated_lufs: row.get(0)?, - true_peak: row.get(1)?, - recommended_gain_db: row.get(2)?, - target_lufs: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ) - .optional() - .map_err(|e| e.to_string())?; - if row.is_some() { - return Ok(row); + if let Some(s) = query_latest_loudness_scoped(&conn, server_id, track_id)? { + return Ok(Some(s)); + } + if !server_id.is_empty() { + if let Some(s) = query_latest_loudness_scoped(&conn, "", track_id)? { + let _ = relabel_legacy_to_server(&conn, server_id, track_id); + return Ok(Some(s)); } } Ok(None) } + + /// Copy any legacy (`server_id=''`) analysis rows for `track_id` (both id + /// variants) onto `server_id` via `INSERT OR IGNORE` — best-effort, never + /// clobbers an existing server-scoped row. Exposed for the exact-key read + /// command, which re-tags after a legacy hit. No-op when `server_id` is empty. + pub fn relabel_legacy_to_server(&self, server_id: &str, track_id: &str) -> Result<(), String> { + if server_id.is_empty() { + return Ok(()); + } + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + relabel_legacy_to_server(&conn, server_id, track_id).map_err(|e| e.to_string()) + } +} + +/// Server-scoped variant of the "latest waveform for this track" lookup: filters +/// `waveform_cache` to `server_id` and tries both id variants (bare ↔ `stream:`). +fn query_latest_waveform_scoped( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> Result, String> { + const SQL: &str = r#" + SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at + FROM waveform_cache w + JOIN analysis_track a + ON a.server_id = w.server_id + AND a.track_id = w.track_id + AND a.md5_16kb = w.md5_16kb + WHERE w.server_id = ?1 + AND w.track_id = ?2 + AND a.waveform_algo_version = ?3 + ORDER BY w.updated_at DESC + LIMIT 1 + "#; + for tid in track_id_cache_variants(track_id) { + let row = conn + .query_row(SQL, params![server_id, tid, WAVEFORM_ALGO_VERSION], |row| { + Ok(WaveformEntry { + bins: row.get(0)?, + bin_count: row.get(1)?, + is_partial: row.get::<_, i64>(2)? != 0, + known_until_sec: row.get(3)?, + duration_sec: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .optional() + .map_err(|e| e.to_string())?; + if let Some(e) = row { + if waveform_cache_blob_len_ok(&e.bins, e.bin_count) { + return Ok(Some(e)); + } + } + } + Ok(None) +} + +/// Server-scoped variant of the "latest loudness for this track" lookup. +fn query_latest_loudness_scoped( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> Result, String> { + const SQL: &str = r#" + SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at + FROM loudness_cache l + JOIN analysis_track a + ON a.server_id = l.server_id + AND a.track_id = l.track_id + AND a.md5_16kb = l.md5_16kb + WHERE l.server_id = ?1 + AND l.track_id = ?2 + AND a.loudness_algo_version = ?3 + ORDER BY l.updated_at DESC + LIMIT 1 + "#; + for tid in track_id_cache_variants(track_id) { + let row = conn + .query_row(SQL, params![server_id, tid, LOUDNESS_ALGO_VERSION], |row| { + Ok(LoudnessSnapshot { + integrated_lufs: row.get(0)?, + true_peak: row.get(1)?, + recommended_gain_db: row.get(2)?, + target_lufs: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .optional() + .map_err(|e| e.to_string())?; + if row.is_some() { + return Ok(row); + } + } + Ok(None) +} + +/// Lazy re-tag: copy legacy (`server_id=''`) `analysis_track` + `waveform_cache` + +/// `loudness_cache` rows for every id variant of `track_id` onto `server_id`. +/// `INSERT OR IGNORE` so an already-present server-scoped row (e.g. a precise +/// playback-derived analysis) is never overwritten. Best-effort, no transaction: +/// the rows are individually consistent and a partial copy still leaves the +/// legacy rows readable via fallback. +fn relabel_legacy_to_server( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> rusqlite::Result<()> { + for tid in track_id_cache_variants(track_id) { + conn.execute( + r#" + INSERT OR IGNORE INTO analysis_track + (server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + SELECT ?1, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at + FROM analysis_track WHERE server_id = '' AND track_id = ?2 + "#, + params![server_id, tid], + )?; + conn.execute( + r#" + INSERT OR IGNORE INTO waveform_cache + (server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at) + SELECT ?1, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at + FROM waveform_cache WHERE server_id = '' AND track_id = ?2 + "#, + params![server_id, tid], + )?; + conn.execute( + r#" + INSERT OR IGNORE INTO loudness_cache + (server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at) + SELECT ?1, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at + FROM loudness_cache WHERE server_id = '' AND track_id = ?2 + "#, + params![server_id, tid], + )?; + } + Ok(()) } fn analysis_db_path(app: &tauri::AppHandle) -> Result { @@ -399,46 +543,91 @@ fn configure_connection(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } -fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> { +/// One-shot safety net before the first table-rewriting migration (002 +/// `server_id`). Snapshots the existing DB via `VACUUM INTO` — a transactionally +/// consistent copy even with WAL — to `.pre-v.bak`, so a catastrophic +/// failure the migration transaction can't cover (disk full at COMMIT, +/// filesystem corruption, a rebuild bug) still leaves the original recoverable. +/// Skipped for a fresh DB or one already at the target version. The analysis +/// cache is small (~1 KB/track), so the copy is cheap. +fn backup_before_pending_migration(db_path: &Path) -> Result<(), String> { + if !db_path.exists() { + return Ok(()); // fresh DB — nothing to protect + } + let conn = Connection::open(db_path).map_err(|e| e.to_string())?; + // `schema_migrations` may not exist yet on a pre-versioning DB → treat the + // missing table as version 0 so the backup runs before 002 rewrites tables. + let applied: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_migrations", + [], + |r| r.get(0), + ) + .unwrap_or(0); + if applied >= ANALYSIS_DB_SCHEMA_VERSION { + return Ok(()); // already at head — no rewrite pending + } + let backup_path = db_path.with_file_name(format!( + "audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak" + )); + // `VACUUM INTO` fails if the target exists; drop a stale backup from an + // interrupted earlier attempt (the snapshot is re-creatable). + if backup_path.exists() { + std::fs::remove_file(&backup_path).map_err(|e| e.to_string())?; + } + // Documented literal form `VACUUM INTO ''`; the local path is escaped + // for the SQL string literal (single-quote doubling) so an apostrophe in a + // user's home dir can't break or inject the statement. + let escaped = backup_path.to_string_lossy().replace('\'', "''"); + conn.execute_batch(&format!("VACUUM INTO '{escaped}';")) + .map_err(|e| format!("analysis pre-migration backup failed: {e}"))?; + Ok(()) +} + +fn run_migrations(conn: &mut Connection) -> rusqlite::Result<()> { + run_migrations_with(conn, MIGRATIONS) +} + +/// Applies every embedded migration not yet recorded in `schema_migrations`. +/// Each migration runs in its own transaction that commits the schema change +/// *and* its version marker together — a failure or crash rolls the whole +/// migration back, and the next start retries it cleanly. Idempotent across +/// reopens. Forward-only: an unknown future version on the DB is left alone +/// (the analysis cache is a rebuildable derived store, so there is no +/// breaking-bump drop/resync like the library DB). +/// +/// Split out (test-friendly) so the migration set can be exercised against an +/// in-memory connection. +pub(crate) fn run_migrations_with( + conn: &mut Connection, + migrations: &[(i64, &str)], +) -> rusqlite::Result<()> { conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS analysis_track ( - track_id TEXT NOT NULL, - md5_16kb TEXT NOT NULL, - status TEXT NOT NULL, - waveform_algo_version INTEGER NOT NULL, - loudness_algo_version INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (track_id, md5_16kb) - ); - - CREATE TABLE IF NOT EXISTS waveform_cache ( - track_id TEXT NOT NULL, - md5_16kb TEXT NOT NULL, - bins BLOB NOT NULL, - bin_count INTEGER NOT NULL, - is_partial INTEGER NOT NULL, - known_until_sec REAL NOT NULL, - duration_sec REAL NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (track_id, md5_16kb) - ); - - CREATE TABLE IF NOT EXISTS loudness_cache ( - track_id TEXT NOT NULL, - md5_16kb TEXT NOT NULL, - integrated_lufs REAL NOT NULL, - true_peak REAL NOT NULL, - recommended_gain_db REAL NOT NULL, - target_lufs REAL NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (track_id, md5_16kb, target_lufs) - ); - - CREATE INDEX IF NOT EXISTS idx_analysis_track_status - ON analysis_track(status); - "#, + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + );", )?; + + let mut ordered: Vec<(i64, &str)> = migrations.to_vec(); + ordered.sort_by_key(|(v, _)| *v); + for (version, sql) in ordered { + let already: i64 = conn.query_row( + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?1", + params![version], + |row| row.get(0), + )?; + if already > 0 { + continue; + } + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))", + params![version], + )?; + tx.commit()?; + } Ok(()) } @@ -448,6 +637,15 @@ mod tests { fn key(track_id: &str) -> TrackKey { TrackKey { + server_id: String::new(), + track_id: track_id.to_string(), + md5_16kb: "deadbeef".to_string(), + } + } + + fn key_on(server_id: &str, track_id: &str) -> TrackKey { + TrackKey { + server_id: server_id.to_string(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), } @@ -522,7 +720,15 @@ mod tests { .unwrap() .map(|r| r.unwrap()) .collect(); - assert_eq!(tables, vec!["analysis_track", "loudness_cache", "waveform_cache"]); + assert_eq!( + tables, + vec![ + "analysis_track", + "loudness_cache", + "schema_migrations", + "waveform_cache" + ] + ); } // ── waveform roundtrip ──────────────────────────────────────────────────── @@ -640,7 +846,7 @@ mod tests { cache.touch_track_status(&k, "ok").unwrap(); cache.upsert_waveform(&k, &waveform(4, false)).unwrap(); // Insert under stream:abc, look up with bare abc. - let got = cache.get_latest_waveform_for_track("abc").unwrap(); + let got = cache.get_latest_waveform_for_track("", "abc").unwrap(); assert!(got.is_some(), "bare-id lookup must find stream-prefixed row"); } @@ -650,7 +856,7 @@ mod tests { let k = key("abc"); cache.touch_track_status(&k, "ok").unwrap(); cache.upsert_loudness(&k, &loudness(-14.0)).unwrap(); - let got = cache.get_latest_loudness_for_track("stream:abc").unwrap(); + let got = cache.get_latest_loudness_for_track("", "stream:abc").unwrap(); assert!(got.is_some(), "stream-prefixed lookup must find bare row"); } @@ -662,16 +868,16 @@ mod tests { let k = key("abc"); cache.touch_track_status(&k, "ok").unwrap(); - assert!(!cache.cpu_seed_redundant_for_track("abc").unwrap()); + assert!(!cache.cpu_seed_redundant_for_track("", "abc").unwrap()); cache.upsert_waveform(&k, &waveform(4, false)).unwrap(); assert!( - !cache.cpu_seed_redundant_for_track("abc").unwrap(), + !cache.cpu_seed_redundant_for_track("", "abc").unwrap(), "waveform alone is not enough" ); cache.upsert_loudness(&k, &loudness(-14.0)).unwrap(); - assert!(cache.cpu_seed_redundant_for_track("abc").unwrap()); + assert!(cache.cpu_seed_redundant_for_track("", "abc").unwrap()); } // ── deletes ─────────────────────────────────────────────────────────────── @@ -686,7 +892,7 @@ mod tests { cache.upsert_loudness(&bare, &loudness(-14.0)).unwrap(); cache.upsert_loudness(&prefixed, &loudness(-14.0)).unwrap(); - let deleted = cache.delete_loudness_for_track_id("abc").unwrap(); + let deleted = cache.delete_loudness_for_track_id("", "abc").unwrap(); assert_eq!(deleted, 2, "delete must remove both bare and stream:abc rows"); assert!(!cache.loudness_row_exists_for_key(&bare).unwrap()); assert!(!cache.loudness_row_exists_for_key(&prefixed).unwrap()); @@ -702,7 +908,7 @@ mod tests { cache.upsert_waveform(&bare, &waveform(4, false)).unwrap(); cache.upsert_waveform(&prefixed, &waveform(4, false)).unwrap(); - let deleted = cache.delete_waveform_for_track_id("abc").unwrap(); + let deleted = cache.delete_waveform_for_track_id("", "abc").unwrap(); assert_eq!(deleted, 2); assert!(cache.get_waveform(&bare).unwrap().is_none()); assert!(cache.get_waveform(&prefixed).unwrap().is_none()); @@ -711,10 +917,102 @@ mod tests { #[test] fn delete_with_empty_or_whitespace_track_id_is_noop() { let cache = AnalysisCache::open_in_memory(); - assert_eq!(cache.delete_waveform_for_track_id("").unwrap(), 0); - assert_eq!(cache.delete_waveform_for_track_id(" ").unwrap(), 0); - assert_eq!(cache.delete_loudness_for_track_id("").unwrap(), 0); - assert_eq!(cache.delete_loudness_for_track_id(" ").unwrap(), 0); + assert_eq!(cache.delete_waveform_for_track_id("", "").unwrap(), 0); + assert_eq!(cache.delete_waveform_for_track_id("", " ").unwrap(), 0); + assert_eq!(cache.delete_loudness_for_track_id("", "").unwrap(), 0); + assert_eq!(cache.delete_loudness_for_track_id("", " ").unwrap(), 0); + } + + #[test] + fn delete_scoped_to_server_keeps_other_servers_rows() { + // A reseed on server-a must not wipe server-b's analysis for the same + // bare track_id; the legacy '' pool is cleared alongside server-a. + let cache = AnalysisCache::open_in_memory(); + let on_a = key_on("server-a", "t"); + let on_b = key_on("server-b", "t"); + let legacy = key_on("", "t"); + for k in [&on_a, &on_b, &legacy] { + cache.touch_track_status(k, "ok").unwrap(); + cache.upsert_waveform(k, &waveform(4, false)).unwrap(); + cache.upsert_loudness(k, &loudness(-14.0)).unwrap(); + } + + let deleted = cache.delete_waveform_for_track_id("server-a", "t").unwrap(); + assert_eq!(deleted, 2, "server-a + legacy '' waveform rows removed"); + assert!(cache.get_waveform(&on_a).unwrap().is_none()); + assert!(cache.get_waveform(&legacy).unwrap().is_none()); + assert!( + cache.get_waveform(&on_b).unwrap().is_some(), + "another server's waveform must survive a scoped reseed" + ); + + let deleted_l = cache.delete_loudness_for_track_id("server-a", "t").unwrap(); + assert_eq!(deleted_l, 2); + assert!(cache.loudness_row_exists_for_key(&on_b).unwrap()); + } + + // ── server scope: read fallback + lazy re-tag ───────────────────────────── + + #[test] + fn get_latest_waveform_falls_back_to_legacy_and_retags() { + // A pre-002 blob lives under server_id=''. A read for a real server must + // find it via fallback and re-tag it under the server-scoped key. + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("", "t"); + cache.touch_track_status(&legacy, "ready").unwrap(); + cache.upsert_waveform(&legacy, &waveform(4, false)).unwrap(); + cache.upsert_loudness(&legacy, &loudness(-14.0)).unwrap(); + + // server-a has no scoped row yet → fallback returns the legacy blob. + assert!(cache.get_waveform(&key_on("server-a", "t")).unwrap().is_none()); + assert!(cache.get_latest_waveform_for_track("server-a", "t").unwrap().is_some()); + + // Re-tag side effect: the exact server-scoped key now resolves directly. + assert!( + cache.get_waveform(&key_on("server-a", "t")).unwrap().is_some(), + "legacy hit must be re-tagged under the server scope" + ); + // Legacy row is preserved (copy, not move). + assert!(cache.get_waveform(&legacy).unwrap().is_some()); + } + + #[test] + fn retag_does_not_clobber_existing_server_scoped_row() { + // server-a already has a precise (playback-derived) row; a legacy hit must + // not overwrite it via INSERT OR IGNORE. + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("", "t"); + cache.touch_track_status(&legacy, "ready").unwrap(); + cache.upsert_waveform(&legacy, &waveform(4, true)).unwrap(); + cache.upsert_loudness(&legacy, &loudness(-14.0)).unwrap(); + + let on_a = key_on("server-a", "t"); + cache.touch_track_status(&on_a, "ready").unwrap(); + let precise = WaveformEntry { is_partial: false, ..waveform(4, false) }; + cache.upsert_waveform(&on_a, &precise).unwrap(); + cache.upsert_loudness(&on_a, &loudness(-14.0)).unwrap(); + + cache.relabel_legacy_to_server("server-a", "t").unwrap(); + let got = cache.get_waveform(&on_a).unwrap().expect("server row present"); + assert!(!got.is_partial, "precise server-scoped row must be preserved"); + } + + #[test] + fn get_latest_loudness_legacy_fallback_scopes_to_requested_server() { + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("", "t"); + cache.touch_track_status(&legacy, "ready").unwrap(); + cache.upsert_loudness(&legacy, &loudness(-12.0)).unwrap(); + + // server-b has its own distinct loudness → exact hit, no fallback. + let on_b = key_on("server-b", "t"); + cache.touch_track_status(&on_b, "ready").unwrap(); + cache.upsert_loudness(&on_b, &loudness(-20.0)).unwrap(); + + let a = cache.get_latest_loudness_for_track("server-a", "t").unwrap().unwrap(); + assert_eq!(a.target_lufs, -12.0, "server-a falls back to legacy blob"); + let b = cache.get_latest_loudness_for_track("server-b", "t").unwrap().unwrap(); + assert_eq!(b.target_lufs, -20.0, "server-b uses its own scoped blob, not legacy"); } #[test] @@ -748,4 +1046,176 @@ mod tests { .unwrap(); assert_eq!(status, "done"); } + + // ── schema migrations (002 server_id) ───────────────────────────────────── + + #[test] + fn run_migrations_records_all_versions_and_is_idempotent() { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + // Second run is a no-op (every version already recorded). + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + let versions: Vec = conn + .prepare("SELECT version FROM schema_migrations ORDER BY version") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!(versions, (1..=ANALYSIS_DB_SCHEMA_VERSION).collect::>()); + } + + #[test] + fn migration_002_preserves_legacy_rows_under_empty_server_id() { + // Simulate a real pre-002 user DB: old schema + one row per table, no + // schema_migrations. + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(MIGRATION_001_BASELINE).unwrap(); + conn.execute( + "INSERT INTO analysis_track (track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + VALUES ('t1','m1','ready',?1,?2,123)", + params![WAVEFORM_ALGO_VERSION, LOUDNESS_ALGO_VERSION], + ) + .unwrap(); + conn.execute( + "INSERT INTO waveform_cache (track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at) + VALUES ('t1','m1',?1,4,0,0.0,60.0,123)", + params![vec![0u8; 8]], + ) + .unwrap(); + conn.execute( + "INSERT INTO loudness_cache (track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at) + VALUES ('t1','m1',-14.0,-1.0,0.0,-14.0,123)", + [], + ) + .unwrap(); + + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + + // No data lost; legacy rows now carry server_id = ''. + let track_sid: String = conn + .query_row("SELECT server_id FROM analysis_track WHERE track_id='t1'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(track_sid, ""); + let waveforms: i64 = conn + .query_row("SELECT COUNT(*) FROM waveform_cache WHERE server_id=''", [], |r| r.get(0)) + .unwrap(); + assert_eq!(waveforms, 1); + let loudness: i64 = conn + .query_row("SELECT COUNT(*) FROM loudness_cache WHERE server_id=''", [], |r| r.get(0)) + .unwrap(); + assert_eq!(loudness, 1); + + // The legacy blob is readable through the cache API under the '' key. + let cache = AnalysisCache { conn: Mutex::new(conn) }; + let legacy_key = TrackKey { + server_id: String::new(), + track_id: "t1".to_string(), + md5_16kb: "m1".to_string(), + }; + assert!(cache.get_waveform(&legacy_key).unwrap().is_some()); + } + + #[test] + fn server_id_scopes_exact_key_lookups() { + let cache = AnalysisCache::open_in_memory(); + let on_a = key_on("server-a", "t"); + let on_b = key_on("server-b", "t"); + cache.touch_track_status(&on_a, "ready").unwrap(); + cache.touch_track_status(&on_b, "ready").unwrap(); + // Only server-a has a waveform. + cache.upsert_waveform(&on_a, &waveform(4, false)).unwrap(); + + assert!(cache.get_waveform(&on_a).unwrap().is_some()); + assert!( + cache.get_waveform(&on_b).unwrap().is_none(), + "exact lookup must not return another server's analysis" + ); + + // Same (track_id, md5_16kb) under two server ids are independent rows. + let conn = cache.conn.lock().unwrap(); + let rows: i64 = conn + .query_row("SELECT COUNT(*) FROM analysis_track WHERE track_id='t'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(rows, 2); + } + + // ── pre-migration backup (on-disk; VACUUM INTO snapshot) ────────────────── + + fn unique_temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static CTR: AtomicU64 = AtomicU64::new(0); + let n = CTR.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("psysonic-analysis-{tag}-{nanos}-{n}")); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn backup_file(dir: &Path) -> PathBuf { + dir.join(format!("audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak")) + } + + #[test] + fn backup_snapshots_pre_v2_db_and_overwrites_stale() { + let dir = unique_temp_dir("bkp-create"); + let db_path = dir.join("audio-analysis.sqlite"); + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch(MIGRATION_001_BASELINE).unwrap(); + conn.execute( + "INSERT INTO analysis_track (track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at) + VALUES ('t','m','ready',?1,?2,1)", + params![WAVEFORM_ALGO_VERSION, LOUDNESS_ALGO_VERSION], + ) + .unwrap(); + } + + backup_before_pending_migration(&db_path).unwrap(); + + let backup = backup_file(&dir); + assert!(backup.exists(), "backup snapshot must be written"); + // The snapshot is a valid DB carrying the original row. + let bconn = Connection::open(&backup).unwrap(); + let rows: i64 = bconn + .query_row("SELECT COUNT(*) FROM analysis_track", [], |r| r.get(0)) + .unwrap(); + assert_eq!(rows, 1); + drop(bconn); + + // A second call overwrites the stale snapshot (VACUUM INTO needs a free + // target) instead of failing. + backup_before_pending_migration(&db_path).unwrap(); + assert!(backup.exists()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn backup_skips_when_db_absent() { + let dir = unique_temp_dir("bkp-absent"); + let db_path = dir.join("audio-analysis.sqlite"); + backup_before_pending_migration(&db_path).unwrap(); + assert!(!backup_file(&dir).exists(), "no backup for a fresh (absent) DB"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn backup_skips_when_already_at_head() { + let dir = unique_temp_dir("bkp-head"); + let db_path = dir.join("audio-analysis.sqlite"); + { + let mut conn = Connection::open(&db_path).unwrap(); + run_migrations_with(&mut conn, MIGRATIONS).unwrap(); + } + backup_before_pending_migration(&db_path).unwrap(); + assert!( + !backup_file(&dir).exists(), + "no backup when the DB is already at the target version" + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 8237155f..58dfc316 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -30,9 +30,14 @@ pub enum AnalysisBackfillEnqueueKind { RunningSkipped, } +/// One queued HTTP-backfill job: `(track_id, url, server_id)`. Dedup is by +/// `track_id` (a track is backfilled at most once at a time); `server_id` rides +/// along to scope the eventual cache write and follows the latest enqueue. +type BackfillJob = (String, String, String); + #[derive(Default)] pub struct AnalysisBackfillQueueState { - pub deque: VecDeque<(String, String)>, + pub deque: VecDeque, /// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque). pub in_progress: Option, } @@ -40,13 +45,13 @@ pub struct AnalysisBackfillQueueState { impl AnalysisBackfillQueueState { fn is_reserved(&self, tid: &str) -> bool { self.in_progress.as_deref() == Some(tid) - || self.deque.iter().any(|(t, _)| t.as_str() == tid) + || self.deque.iter().any(|(t, _, _)| t.as_str() == tid) } - fn try_pop_next(&mut self) -> Option<(String, String)> { - let (tid, url) = self.deque.pop_front()?; - self.in_progress = Some(tid.clone()); - Some((tid, url)) + fn try_pop_next(&mut self) -> Option { + let job = self.deque.pop_front()?; + self.in_progress = Some(job.0.clone()); + Some(job) } fn finish_job(&mut self, tid: &str) { @@ -57,6 +62,7 @@ impl AnalysisBackfillQueueState { pub fn enqueue( &mut self, + server_id: String, tid: String, url: String, high_priority: bool, @@ -69,15 +75,15 @@ impl AnalysisBackfillQueueState { if self.in_progress.as_deref() == Some(tref) { return AnalysisBackfillEnqueueKind::RunningSkipped; } - self.deque.retain(|(t, _)| t != &tid); - self.deque.push_front((tid, url)); + self.deque.retain(|(t, _, _)| t != &tid); + self.deque.push_front((tid, url, server_id)); return AnalysisBackfillEnqueueKind::ReorderedFront; } if high_priority { - self.deque.push_front((tid, url)); + self.deque.push_front((tid, url, server_id)); AnalysisBackfillEnqueueKind::NewFront } else { - self.deque.push_back((tid, url)); + self.deque.push_back((tid, url, server_id)); AnalysisBackfillEnqueueKind::NewBack } } @@ -85,7 +91,7 @@ impl AnalysisBackfillQueueState { pub fn prune_queued_not_in(&mut self, keep_track_ids: &HashSet<&str>) -> usize { let before = self.deque.len(); self.deque - .retain(|(track_id, _)| keep_track_ids.contains(track_id.as_str())); + .retain(|(track_id, _, _)| keep_track_ids.contains(track_id.as_str())); before.saturating_sub(self.deque.len()) } } @@ -125,17 +131,19 @@ pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc Result { if let Some(cache) = app.try_state::() { - if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) { + if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) { return Ok(true); } } let high = analysis_backfill_is_current_track(app, track_id); let outcome = submit_analysis_cpu_seed( app.clone(), + server_id.to_string(), track_id.to_string(), bytes.to_vec(), high, @@ -147,7 +155,7 @@ pub async fn enqueue_analysis_seed( })?; let has_loudness = app .try_state::() - .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) + .and_then(|cache| cache.get_latest_loudness_for_track(server_id, track_id).ok().flatten()) .is_some(); crate::app_deprintln!( "[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}", @@ -160,6 +168,7 @@ pub async fn enqueue_analysis_seed( async fn analysis_backfill_download_and_seed( app: &tauri::AppHandle, + server_id: &str, track_id: &str, url: &str, ) -> Result { @@ -176,7 +185,7 @@ async fn analysis_backfill_download_and_seed( if bytes.is_empty() { return Err("empty response".to_string()); } - enqueue_analysis_seed(app, track_id, &bytes).await + enqueue_analysis_seed(app, server_id, track_id, &bytes).await } async fn analysis_backfill_worker_loop( @@ -188,7 +197,7 @@ async fn analysis_backfill_worker_loop( if wake_rx.recv().await.is_none() { break; } - while let Some((track_id, url)) = { + while let Some((track_id, url, server_id)) = { let mut st = shared .state .lock() @@ -196,7 +205,7 @@ async fn analysis_backfill_worker_loop( st.try_pop_next() } { crate::app_deprintln!("[analysis] backfill worker: start track_id={}", track_id); - let result = analysis_backfill_download_and_seed(&app, &track_id, &url).await; + let result = analysis_backfill_download_and_seed(&app, &server_id, &track_id, &url).await; match &result { Ok(has_loudness) => crate::app_deprintln!( "[analysis] backfill ready: {} (has_loudness={})", @@ -238,6 +247,9 @@ type SeedDoneSender = type RunningSeedJob = (String, Arc>>); struct AnalysisCpuSeedJob { + /// Playback server scope for the write key. Empty = legacy '' (caller did not + /// know the server); the read path's fallback + lazy re-tag covers it. + server_id: String, track_id: String, bytes: Vec, waiters: Vec, @@ -253,6 +265,7 @@ struct AnalysisCpuSeedQueueState { impl AnalysisCpuSeedQueueState { fn enqueue( &mut self, + server_id: String, track_id: String, bytes: Vec, high_priority: bool, @@ -275,6 +288,8 @@ impl AnalysisCpuSeedQueueState { if let Some(pos) = self.deque.iter().position(|j| j.track_id == track_id) { let mut job = self.deque.remove(pos).unwrap(); + // Latest submission wins for both bytes and server scope. + job.server_id = server_id; job.bytes = bytes; job.waiters.push(done_tx); let kind = if high_priority { @@ -288,6 +303,7 @@ impl AnalysisCpuSeedQueueState { } let job = AnalysisCpuSeedJob { + server_id, track_id: track_id.clone(), bytes, waiters: vec![done_tx], @@ -422,10 +438,11 @@ async fn analysis_cpu_seed_worker_loop( }; let tid_log = job.track_id.clone(); let app2 = app.clone(); + let sid = job.server_id.clone(); let tid = job.track_id.clone(); let bytes = job.bytes; let outcome = tokio::task::spawn_blocking(move || { - analysis_cache::seed_from_bytes_execute(&app2, &tid, &bytes) + analysis_cache::seed_from_bytes_execute(&app2, &sid, &tid, &bytes) }) .await .unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}"))); @@ -497,6 +514,7 @@ pub fn prune_analysis_queues( /// re-run loudness refresh / waveform IPC for rows that were already current. pub async fn submit_analysis_cpu_seed( app: tauri::AppHandle, + server_id: String, track_id: String, bytes: Vec, high_priority: bool, @@ -504,7 +522,7 @@ pub async fn submit_analysis_cpu_seed( let shared = analysis_cpu_seed_shared(&app); let rx = { let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); - let (kind, rx) = st.enqueue(track_id.clone(), bytes, high_priority); + let (kind, rx) = st.enqueue(server_id, track_id.clone(), bytes, high_priority); crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} high_priority={high_priority}"); drop(st); shared.ping_worker(); @@ -542,7 +560,7 @@ mod tests { #[test] fn backfill_is_reserved_checks_both_deque_and_in_progress() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("queued".into(), "u".into())); + s.deque.push_back(("queued".into(), "u".into(), String::new())); s.in_progress = Some("active".into()); assert!(s.is_reserved("queued")); assert!(s.is_reserved("active")); @@ -552,8 +570,8 @@ mod tests { #[test] fn backfill_try_pop_next_promotes_head_to_in_progress() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("a".into(), "ua".into())); - s.deque.push_back(("b".into(), "ub".into())); + s.deque.push_back(("a".into(), "ua".into(), String::new())); + s.deque.push_back(("b".into(), "ub".into(), String::new())); let popped = s.try_pop_next().unwrap(); assert_eq!(popped.0, "a"); assert_eq!(s.in_progress.as_deref(), Some("a")); @@ -582,8 +600,8 @@ mod tests { #[test] fn backfill_enqueue_low_priority_appends_to_back() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("first".into(), "u".into())); - let kind = s.enqueue("second".into(), "u2".into(), false); + s.deque.push_back(("first".into(), "u".into(), String::new())); + let kind = s.enqueue(String::new(), "second".into(), "u2".into(), false); assert_eq!(kind, AnalysisBackfillEnqueueKind::NewBack); assert_eq!(s.deque.back().unwrap().0, "second"); } @@ -591,8 +609,8 @@ mod tests { #[test] fn backfill_enqueue_high_priority_pushes_to_front() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("old".into(), "u".into())); - let kind = s.enqueue("hot".into(), "u2".into(), true); + s.deque.push_back(("old".into(), "u".into(), String::new())); + let kind = s.enqueue(String::new(), "hot".into(), "u2".into(), true); assert_eq!(kind, AnalysisBackfillEnqueueKind::NewFront); assert_eq!(s.deque.front().unwrap().0, "hot"); } @@ -600,8 +618,8 @@ mod tests { #[test] fn backfill_enqueue_returns_duplicate_skipped_for_low_prio_dup() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("dup".into(), "u".into())); - let kind = s.enqueue("dup".into(), "u2".into(), false); + s.deque.push_back(("dup".into(), "u".into(), String::new())); + let kind = s.enqueue(String::new(), "dup".into(), "u2".into(), false); assert_eq!(kind, AnalysisBackfillEnqueueKind::DuplicateSkipped); assert_eq!(s.deque.len(), 1); } @@ -612,32 +630,35 @@ mod tests { in_progress: Some("active".into()), ..Default::default() }; - let kind = s.enqueue("active".into(), "u".into(), true); + let kind = s.enqueue(String::new(), "active".into(), "u".into(), true); assert_eq!(kind, AnalysisBackfillEnqueueKind::RunningSkipped); } #[test] fn backfill_enqueue_high_prio_dup_in_deque_reorders_to_front_with_new_url() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("a".into(), "u_a".into())); - s.deque.push_back(("dup".into(), "old_url".into())); - s.deque.push_back(("c".into(), "u_c".into())); - let kind = s.enqueue("dup".into(), "fresh_url".into(), true); + s.deque.push_back(("a".into(), "u_a".into(), String::new())); + s.deque.push_back(("dup".into(), "old_url".into(), String::new())); + s.deque.push_back(("c".into(), "u_c".into(), String::new())); + let kind = s.enqueue("server-1".into(), "dup".into(), "fresh_url".into(), true); assert_eq!(kind, AnalysisBackfillEnqueueKind::ReorderedFront); - assert_eq!(s.deque.front().unwrap(), &("dup".to_string(), "fresh_url".to_string())); - assert_eq!(s.deque.iter().filter(|(t, _)| t == "dup").count(), 1, "no duplicate left behind"); + assert_eq!( + s.deque.front().unwrap(), + &("dup".to_string(), "fresh_url".to_string(), "server-1".to_string()) + ); + assert_eq!(s.deque.iter().filter(|(t, _, _)| t == "dup").count(), 1, "no duplicate left behind"); } #[test] fn backfill_prune_queued_not_in_drops_unkept_entries() { let mut s = AnalysisBackfillQueueState::default(); for tid in ["a", "b", "c", "d"] { - s.deque.push_back((tid.into(), "u".into())); + s.deque.push_back((tid.into(), "u".into(), String::new())); } let keep: HashSet<&str> = ["a", "c"].iter().copied().collect(); let removed = s.prune_queued_not_in(&keep); assert_eq!(removed, 2); - let remaining: Vec<&str> = s.deque.iter().map(|(t, _)| t.as_str()).collect(); + let remaining: Vec<&str> = s.deque.iter().map(|(t, _, _)| t.as_str()).collect(); assert_eq!(remaining, vec!["a", "c"]); } @@ -646,7 +667,7 @@ mod tests { #[test] fn cpu_seed_enqueue_low_prio_appends_to_back() { let mut s = AnalysisCpuSeedQueueState::default(); - let (kind, _rx) = s.enqueue("a".into(), vec![], false); + let (kind, _rx) = s.enqueue(String::new(), "a".into(), vec![], false); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewBack); assert_eq!(s.deque.len(), 1); } @@ -654,8 +675,8 @@ mod tests { #[test] fn cpu_seed_enqueue_high_prio_pushes_to_front() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("first".into(), vec![], false); - let (kind, _r2) = s.enqueue("hot".into(), vec![], true); + let (_, _r1) = s.enqueue(String::new(), "first".into(), vec![], false); + let (kind, _r2) = s.enqueue(String::new(), "hot".into(), vec![], true); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewFront); assert_eq!(s.deque.front().unwrap().track_id, "hot"); } @@ -663,20 +684,21 @@ mod tests { #[test] fn cpu_seed_enqueue_existing_low_prio_merges_at_back() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("dup".into(), vec![1, 2, 3], false); - let (kind, _r2) = s.enqueue("dup".into(), vec![4, 5, 6], false); + let (_, _r1) = s.enqueue("server-a".into(), "dup".into(), vec![1, 2, 3], false); + let (kind, _r2) = s.enqueue("server-b".into(), "dup".into(), vec![4, 5, 6], false); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::MergedQueued); assert_eq!(s.deque.len(), 1); assert_eq!(s.deque[0].bytes, vec![4, 5, 6], "fresh bytes overwrite"); + assert_eq!(s.deque[0].server_id, "server-b", "latest server scope wins on merge"); assert_eq!(s.deque[0].waiters.len(), 2, "both waiters attached"); } #[test] fn cpu_seed_enqueue_existing_high_prio_reorders_to_front() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("first".into(), vec![], false); - let (_, _r2) = s.enqueue("dup".into(), vec![], false); - let (kind, _r3) = s.enqueue("dup".into(), vec![], true); + let (_, _r1) = s.enqueue(String::new(), "first".into(), vec![], false); + let (_, _r2) = s.enqueue(String::new(), "dup".into(), vec![], false); + let (kind, _r3) = s.enqueue(String::new(), "dup".into(), vec![], true); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::ReorderedFront); assert_eq!(s.deque.front().unwrap().track_id, "dup"); } @@ -686,7 +708,7 @@ mod tests { let mut s = AnalysisCpuSeedQueueState::default(); let followers = Arc::new(Mutex::new(Vec::new())); s.running = Some(("active".into(), followers.clone())); - let (kind, _rx) = s.enqueue("active".into(), vec![], false); + let (kind, _rx) = s.enqueue(String::new(), "active".into(), vec![], false); assert_eq!(kind, AnalysisCpuSeedEnqueueKind::RunningFollower); assert_eq!(followers.lock().unwrap().len(), 1, "follower channel attached"); assert_eq!(s.deque.len(), 0, "follower does not occupy a queue slot"); @@ -695,10 +717,10 @@ mod tests { #[test] fn cpu_seed_prune_returns_removed_jobs_and_waiter_count() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, _r1) = s.enqueue("a".into(), vec![], false); - let (_, _r2) = s.enqueue("b".into(), vec![], false); - let (_, _r3) = s.enqueue("a".into(), vec![], false); // merged: 2 waiters on a - let (_, _r4) = s.enqueue("c".into(), vec![], false); + let (_, _r1) = s.enqueue(String::new(), "a".into(), vec![], false); + let (_, _r2) = s.enqueue(String::new(), "b".into(), vec![], false); + let (_, _r3) = s.enqueue(String::new(), "a".into(), vec![], false); // merged: 2 waiters on a + let (_, _r4) = s.enqueue(String::new(), "c".into(), vec![], false); let keep: HashSet<&str> = ["a"].iter().copied().collect(); let (removed_jobs, removed_waiters) = s.prune_queued_not_in(&keep); @@ -711,7 +733,7 @@ mod tests { #[test] fn cpu_seed_prune_sends_err_to_dropped_waiters() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, rx) = s.enqueue("doomed".into(), vec![], false); + let (_, rx) = s.enqueue(String::new(), "doomed".into(), vec![], false); let keep: HashSet<&str> = HashSet::new(); let _ = s.prune_queued_not_in(&keep); // After pruning, the waiter receives the cancellation Err. diff --git a/src-tauri/crates/psysonic-analysis/src/commands.rs b/src-tauri/crates/psysonic-analysis/src/commands.rs index feb974b6..a69296f0 100644 --- a/src-tauri/crates/psysonic-analysis/src/commands.rs +++ b/src-tauri/crates/psysonic-analysis/src/commands.rs @@ -49,43 +49,63 @@ pub struct LoudnessCachePayload { pub updated_at: i64, } -/// AppHandle-free helper: looks up a waveform by exact `(track_id, md5_16kb)` -/// key and converts the `WaveformEntry` into the JSON-serialisable -/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can -/// be tested with `AnalysisCache::open_in_memory()` and direct upserts. +/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id, +/// md5_16kb)` key, falling back to the legacy `''` scope and re-tagging it onto +/// `server_id` on a hit (best-effort). Converts the `WaveformEntry` into the +/// JSON-serialisable `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] +/// so it can be tested with `AnalysisCache::open_in_memory()` and direct upserts. pub fn get_waveform_payload( cache: &analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, md5_16kb: &str, ) -> Result, String> { - let key = analysis_cache::TrackKey { + let exact = analysis_cache::TrackKey { + server_id: server_id.to_string(), track_id: track_id.to_string(), md5_16kb: md5_16kb.to_string(), }; - Ok(cache.get_waveform(&key)?.map(WaveformCachePayload::from)) + if let Some(e) = cache.get_waveform(&exact)? { + return Ok(Some(WaveformCachePayload::from(e))); + } + if !server_id.is_empty() { + let legacy = analysis_cache::TrackKey { + server_id: String::new(), + track_id: track_id.to_string(), + md5_16kb: md5_16kb.to_string(), + }; + if let Some(e) = cache.get_waveform(&legacy)? { + let _ = cache.relabel_legacy_to_server(server_id, track_id); + return Ok(Some(WaveformCachePayload::from(e))); + } + } + Ok(None) } -/// AppHandle-free helper: looks up the latest waveform for `track_id` -/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`]. +/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)` +/// across all id variants (bare ↔ `stream:` prefix) with legacy fallback + lazy +/// re-tag. See [`get_waveform_payload`]. pub fn get_waveform_payload_for_track( cache: &analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, ) -> Result, String> { Ok(cache - .get_latest_waveform_for_track(track_id)? + .get_latest_waveform_for_track(server_id, track_id)? .map(WaveformCachePayload::from)) } -/// AppHandle-free helper: looks up the latest loudness row for `track_id` -/// and recomputes `recommended_gain_db` against the optional requested target -/// (clamped to [-30, -8]). When `target_lufs` is `None`, the cached row's own -/// target is used. +/// AppHandle-free helper: looks up the latest loudness row for `(server_id, +/// track_id)` (legacy fallback + lazy re-tag) and recomputes `recommended_gain_db` +/// against the optional requested target (clamped to [-30, -8]). When +/// `target_lufs` is `None`, the cached row's own target is used. pub fn get_loudness_payload_for_track( cache: &analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, target_lufs: Option, ) -> Result, String> { - Ok(cache.get_latest_loudness_for_track(track_id)?.map(|v| { + Ok(cache.get_latest_loudness_for_track(server_id, track_id)?.map(|v| { let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0); let recommended_gain_db = analysis_cache::recommended_gain_for_target( v.integrated_lufs, @@ -106,9 +126,11 @@ pub fn get_loudness_payload_for_track( pub fn analysis_get_waveform( track_id: String, md5_16kb: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - let result = get_waveform_payload(cache.inner(), &track_id, &md5_16kb); + let server_id = server_id.unwrap_or_default(); + let result = get_waveform_payload(cache.inner(), &server_id, &track_id, &md5_16kb); if let Ok(ref payload) = result { match payload { Some(v) => crate::app_deprintln!( @@ -127,9 +149,11 @@ pub fn analysis_get_waveform( #[tauri::command] pub fn analysis_get_waveform_for_track( track_id: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - let result = get_waveform_payload_for_track(cache.inner(), &track_id); + let server_id = server_id.unwrap_or_default(); + let result = get_waveform_payload_for_track(cache.inner(), &server_id, &track_id); if let Ok(ref payload) = result { match payload { Some(v) => crate::app_deprintln!( @@ -146,25 +170,29 @@ pub fn analysis_get_waveform_for_track( pub fn analysis_get_loudness_for_track( track_id: String, target_lufs: Option, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - get_loudness_payload_for_track(cache.inner(), &track_id, target_lufs) + let server_id = server_id.unwrap_or_default(); + get_loudness_payload_for_track(cache.inner(), &server_id, &track_id, target_lufs) } #[tauri::command] pub fn analysis_delete_loudness_for_track( track_id: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result { - cache.delete_loudness_for_track_id(&track_id) + cache.delete_loudness_for_track_id(&server_id.unwrap_or_default(), &track_id) } #[tauri::command] pub fn analysis_delete_waveform_for_track( track_id: String, + server_id: Option, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result { - cache.delete_waveform_for_track_id(&track_id) + cache.delete_waveform_for_track_id(&server_id.unwrap_or_default(), &track_id) } #[tauri::command] @@ -179,11 +207,13 @@ pub fn analysis_enqueue_seed_from_url( track_id: String, url: String, force: Option, + server_id: Option, app: tauri::AppHandle, ) -> Result<(), String> { if track_id.trim().is_empty() || url.trim().is_empty() { return Ok(()); } + let server_id = server_id.unwrap_or_default(); let force = force.unwrap_or(false); if !force { if let Some(playback) = app.try_state::() { @@ -198,7 +228,7 @@ pub fn analysis_enqueue_seed_from_url( } if !force { if let Some(cache) = app.try_state::() { - if cache.get_latest_loudness_for_track(&track_id)?.is_some() { + if cache.get_latest_loudness_for_track(&server_id, &track_id)?.is_some() { crate::app_deprintln!( "[analysis] backfill skip (already cached): {}", track_id @@ -215,7 +245,7 @@ pub fn analysis_enqueue_seed_from_url( .state .lock() .map_err(|_| "analysis backfill lock poisoned".to_string())?; - st.enqueue(track_id, url, high_priority) + st.enqueue(server_id, track_id, url, high_priority) }; match kind { AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => { @@ -297,6 +327,7 @@ mod tests { fn key(track_id: &str, md5: &str) -> TrackKey { TrackKey { + server_id: String::new(), track_id: track_id.to_string(), md5_16kb: md5.to_string(), } @@ -342,7 +373,7 @@ mod tests { #[test] fn get_waveform_payload_returns_none_for_unknown_key() { let cache = AnalysisCache::open_in_memory(); - let payload = get_waveform_payload(&cache, "missing", "deadbeef").unwrap(); + let payload = get_waveform_payload(&cache, "", "missing", "deadbeef").unwrap(); assert!(payload.is_none()); } @@ -351,7 +382,7 @@ mod tests { let cache = AnalysisCache::open_in_memory(); let bins: Vec = (0..8u8).collect(); upsert_waveform(&cache, "abc", "deadbeef", bins.clone()); - let payload = get_waveform_payload(&cache, "abc", "deadbeef") + let payload = get_waveform_payload(&cache, "", "abc", "deadbeef") .unwrap() .expect("payload exists"); assert_eq!(payload.bins, bins); @@ -367,8 +398,8 @@ mod tests { let cache = AnalysisCache::open_in_memory(); upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]); upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]); - let p1 = get_waveform_payload(&cache, "abc", "aaaa").unwrap().unwrap(); - let p2 = get_waveform_payload(&cache, "abc", "bbbb").unwrap().unwrap(); + let p1 = get_waveform_payload(&cache, "", "abc", "aaaa").unwrap().unwrap(); + let p2 = get_waveform_payload(&cache, "", "abc", "bbbb").unwrap().unwrap(); assert_ne!(p1.bins, p2.bins); } @@ -380,7 +411,7 @@ mod tests { // matching is the whole point of get_latest_waveform_for_track. let cache = AnalysisCache::open_in_memory(); upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]); - let payload = get_waveform_payload_for_track(&cache, "abc") + let payload = get_waveform_payload_for_track(&cache, "", "abc") .unwrap() .expect("bare-id lookup must hit the stream-prefixed row"); assert_eq!(payload.bin_count, 4); @@ -389,7 +420,28 @@ mod tests { #[test] fn get_waveform_for_track_returns_none_for_unknown_track() { let cache = AnalysisCache::open_in_memory(); - assert!(get_waveform_payload_for_track(&cache, "phantom").unwrap().is_none()); + assert!(get_waveform_payload_for_track(&cache, "", "phantom").unwrap().is_none()); + } + + #[test] + fn get_waveform_payload_exact_key_falls_back_to_legacy_and_retags() { + // A pre-002 row lives under server_id=''. The exact-key read for a real + // server must find it via fallback and re-tag it under the server scope. + let cache = AnalysisCache::open_in_memory(); + upsert_waveform(&cache, "abc", "deadbeef", vec![7u8; 8]); // legacy '' row + + let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef") + .unwrap() + .expect("legacy fallback must return the blob"); + assert_eq!(payload.bins, vec![7u8; 8]); + + // Re-tag side effect: the server-scoped exact key now resolves on its own. + let scoped = TrackKey { + server_id: "server-a".to_string(), + track_id: "abc".to_string(), + md5_16kb: "deadbeef".to_string(), + }; + assert!(cache.get_waveform(&scoped).unwrap().is_some()); } // ── get_loudness_payload_for_track ──────────────────────────────────────── @@ -400,7 +452,7 @@ mod tests { upsert_loudness(&cache, "abc", "deadbeef", -14.0); // Cached row: integrated -14, target -14 → gain 0. Request target -10 → // recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard). - let payload = get_loudness_payload_for_track(&cache, "abc", Some(-10.0)) + let payload = get_loudness_payload_for_track(&cache, "", "abc", Some(-10.0)) .unwrap() .expect("loudness row exists"); assert_eq!(payload.target_lufs, -10.0); @@ -415,7 +467,7 @@ mod tests { fn get_loudness_for_track_uses_cached_target_when_request_is_none() { let cache = AnalysisCache::open_in_memory(); upsert_loudness(&cache, "abc", "deadbeef", -16.0); - let payload = get_loudness_payload_for_track(&cache, "abc", None) + let payload = get_loudness_payload_for_track(&cache, "", "abc", None) .unwrap() .unwrap(); assert_eq!(payload.target_lufs, -16.0); @@ -426,11 +478,11 @@ mod tests { let cache = AnalysisCache::open_in_memory(); upsert_loudness(&cache, "abc", "deadbeef", -14.0); // Out-of-range target gets clamped to [-30, -8]. - let too_high = get_loudness_payload_for_track(&cache, "abc", Some(0.0)) + let too_high = get_loudness_payload_for_track(&cache, "", "abc", Some(0.0)) .unwrap() .unwrap(); assert_eq!(too_high.target_lufs, -8.0); - let too_low = get_loudness_payload_for_track(&cache, "abc", Some(-100.0)) + let too_low = get_loudness_payload_for_track(&cache, "", "abc", Some(-100.0)) .unwrap() .unwrap(); assert_eq!(too_low.target_lufs, -30.0); @@ -439,7 +491,7 @@ mod tests { #[test] fn get_loudness_for_track_returns_none_for_unknown_track() { let cache = AnalysisCache::open_in_memory(); - assert!(get_loudness_payload_for_track(&cache, "phantom", None) + assert!(get_loudness_payload_for_track(&cache, "", "phantom", None) .unwrap() .is_none()); } diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index d43e0892..faa49613 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -29,6 +29,11 @@ use super::state::{ChainedInfo, PreloadedTrack}; /// cache to the track when playing `psysonic-local://` (hot/offline). Optional /// for HTTP streams (`playback_identity` is used as fallback). /// +/// `server_id`: app id of the server that owns this track (`playbackServerId ?? +/// activeServerId` on the frontend). Scopes the analysis-cache write key so a +/// later server switch can't surface another server's waveform for the same bare +/// `track_id`. Empty/absent falls back to the legacy `''` scope. +/// /// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no /// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP. #[tauri::command] @@ -45,6 +50,7 @@ pub async fn audio_play( manual: bool, // true = user-initiated skip → bypass crossfade, start immediately hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha) analysis_track_id: Option, + server_id: Option, stream_format_suffix: Option, app: AppHandle, state: State<'_, AudioEngine>, @@ -134,6 +140,11 @@ pub async fn audio_play( .filter(|s| !s.is_empty()); *state.current_analysis_track_id.lock().unwrap() = logical_trim.clone(); let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url); + // Playback server scope for the analysis-cache write key (empty → legacy ''). + let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty()); + // Pin it so the gain-resolution + replay-gain-update + device-resume reads + // scope to this server too (mirrors `current_analysis_track_id`). + *state.current_playback_server_id.lock().unwrap() = analysis_server_id.map(str::to_string); let format_hint = url_format_hint(&url); @@ -145,6 +156,7 @@ pub async fn audio_play( stream_format_suffix: stream_format_suffix.as_deref(), format_hint: format_hint.as_deref(), cache_id_for_tasks: cache_id_for_tasks.as_deref(), + server_id: analysis_server_id, reuse_chained_bytes, }, &state, @@ -239,6 +251,7 @@ pub async fn audio_play( url: &url, gen, cache_id_for_tasks: cache_id_for_tasks.as_deref(), + server_id: analysis_server_id, url_format_hint: format_hint.as_deref(), stream_format_suffix: stream_format_suffix.as_deref(), done_flag: done_flag.clone(), diff --git a/src-tauri/crates/psysonic-audio/src/device_resume.rs b/src-tauri/crates/psysonic-audio/src/device_resume.rs index a953cff5..bea2bc8a 100644 --- a/src-tauri/crates/psysonic-audio/src/device_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/device_resume.rs @@ -143,6 +143,9 @@ pub(crate) async fn try_resume_after_device_change( engine.samples_played.store(0, Ordering::Relaxed); let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000; + // Resume re-plays the current track → scope its analysis writes to the + // pinned playback server (empty → legacy ''). + let resume_server = crate::helpers::current_playback_server_id_str(&engine); let ps: PlaybackSource = match build_playback_source_with_probe_fallback( play_input, @@ -150,6 +153,7 @@ pub(crate) async fn try_resume_after_device_change( url, gen, cache_id_for_tasks: snap.analysis_track_id.as_deref(), + server_id: Some(resume_server.as_str()), url_format_hint: format_hint.as_deref(), stream_format_suffix: stream_format_suffix.as_deref(), done_flag: done_flag.clone(), diff --git a/src-tauri/crates/psysonic-audio/src/engine.rs b/src-tauri/crates/psysonic-audio/src/engine.rs index b8c3fac7..fc9e0468 100644 --- a/src-tauri/crates/psysonic-audio/src/engine.rs +++ b/src-tauri/crates/psysonic-audio/src/engine.rs @@ -82,6 +82,11 @@ pub struct AudioEngine { /// Subsonic song id last passed from JS with `audio_play` (trimmed). Used /// for loudness/waveform cache when the URL is `psysonic-local://…`. pub(crate) current_analysis_track_id: Arc>>, + /// App server id (`playbackServerId ?? activeServerId`) of the current + /// playback, pinned by `audio_play`. Scopes analysis-cache reads (loudness + /// gain, replay-gain updates, device resume) to the right server so a switch + /// can't surface another server's blob for the same bare `track_id`. + pub(crate) current_playback_server_id: Arc>>, /// While a `RangedHttpSource` download task is filling the buffer for this /// `(track_id, play_generation)`, skip `analysis_enqueue_seed_from_url` for the /// same id — otherwise a parallel full GET + Symphonia competes with playback @@ -381,6 +386,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { radio_state: Mutex::new(None), current_playback_url: Arc::new(Mutex::new(None)), current_analysis_track_id: Arc::new(Mutex::new(None)), + current_playback_server_id: Arc::new(Mutex::new(None)), ranged_loudness_seed_hold: Arc::new(Mutex::new(None)), preview_sink: Arc::new(Mutex::new(None)), preview_gen: Arc::new(AtomicU64::new(0)), diff --git a/src-tauri/crates/psysonic-audio/src/helpers.rs b/src-tauri/crates/psysonic-audio/src/helpers.rs index 12bd6255..e70cc913 100644 --- a/src-tauri/crates/psysonic-audio/src/helpers.rs +++ b/src-tauri/crates/psysonic-audio/src/helpers.rs @@ -326,12 +326,14 @@ pub(crate) fn resolve_loudness_gain_from_cache( url: &str, target_lufs: f32, logical_track_id: Option<&str>, + server_id: &str, ) -> Option { resolve_loudness_gain_from_cache_impl( app, url, target_lufs, logical_track_id, + server_id, ResolveLoudnessCacheOpts::default(), ) } @@ -341,6 +343,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl( url: &str, target_lufs: f32, logical_track_id: Option<&str>, + server_id: &str, opts: ResolveLoudnessCacheOpts, ) -> Option { // Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`) @@ -363,7 +366,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl( } return None; }; - resolve_loudness_gain_with_cache(cache.inner(), &track_id, target_lufs, opts) + resolve_loudness_gain_with_cache(cache.inner(), server_id, &track_id, target_lufs, opts) } /// AppHandle-free core of [`resolve_loudness_gain_from_cache_impl`]. Looks up @@ -376,15 +379,16 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl( /// connection's row cache is warm for the next IPC tick. pub(crate) fn resolve_loudness_gain_with_cache( cache: &psysonic_analysis::analysis_cache::AnalysisCache, + server_id: &str, track_id: &str, target_lufs: f32, opts: ResolveLoudnessCacheOpts, ) -> Option { if opts.touch_waveform { // Bind / preload: verify waveform context exists alongside loudness lookup. - let _ = cache.get_latest_waveform_for_track(track_id); + let _ = cache.get_latest_waveform_for_track(server_id, track_id); } - match cache.get_latest_loudness_for_track(track_id) { + match cache.get_latest_loudness_for_track(server_id, track_id) { Ok(Some(row)) if row.integrated_lufs.is_finite() => { let recommended = psysonic_analysis::analysis_cache::recommended_gain_for_target( row.integrated_lufs, @@ -493,6 +497,17 @@ pub(crate) struct TrackGainInputs { /// Read engine state + resolve the loudness cache for a track that's about to /// start playing. JS-supplied `loudness_gain_db` is **not** consulted at bind /// time (only post-cache via `audio_update_replay_gain`). +/// Current playback server scope (`current_playback_server_id`, empty when +/// unset) for scoping analysis-cache reads on the gain-resolution path. +pub(crate) fn current_playback_server_id_str(state: &AudioEngine) -> String { + state + .current_playback_server_id + .lock() + .ok() + .and_then(|g| (*g).clone()) + .unwrap_or_default() +} + pub(crate) fn resolve_track_gain_inputs( state: &AudioEngine, app: &AppHandle, @@ -503,7 +518,9 @@ pub(crate) fn resolve_track_gain_inputs( let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); let norm_mode = state.normalization_engine.load(Ordering::Relaxed); let pre_analysis_db = loudness_pre_analysis_db_for_engine(state); - let cache_loudness_db = resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id); + let server_id = current_playback_server_id_str(state); + let cache_loudness_db = + resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id, &server_id); let effective_loudness_db = if norm_mode == 2 { loudness_gain_db_after_resolve( cache_loudness_db, @@ -736,6 +753,7 @@ pub(crate) async fn fetch_data( /// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP. pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( app: &AppHandle, + server_id: Option<&str>, cache_track_id: Option<&str>, gen: u64, gen_arc: &Arc, @@ -748,6 +766,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( return; } let track_id = track_id.to_string(); + let server_id = server_id.unwrap_or("").to_string(); let bytes = bytes.to_vec(); let app = app.clone(); let gen_arc = gen_arc.clone(); @@ -761,7 +780,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( if gen_arc.load(Ordering::SeqCst) != gen { return; } - if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await { + if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), server_id, track_id.clone(), bytes, high).await { crate::app_eprintln!( "[analysis] in-memory play path seed failed for {}: {}", track_id, @@ -774,6 +793,7 @@ pub(crate) fn spawn_analysis_seed_from_in_memory_bytes( /// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap). pub(crate) fn spawn_analysis_seed_from_spill_file( app: &AppHandle, + server_id: Option<&str>, track_id: &str, spill_path: std::path::PathBuf, gen: u64, @@ -783,6 +803,7 @@ pub(crate) fn spawn_analysis_seed_from_spill_file( if track_id.is_empty() { return; } + let server_id = server_id.unwrap_or("").to_string(); let app = app.clone(); let gen_arc = gen_arc.clone(); let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES; @@ -822,6 +843,7 @@ pub(crate) fn spawn_analysis_seed_from_spill_file( let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id); if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed( app, + server_id, track_id.clone(), bytes, high, @@ -1407,6 +1429,7 @@ mod tests { fn upsert_loudness_row(cache: &AnalysisCache, track_id: &str, integrated: f64, target: f64) { let k = TrackKey { + server_id: String::new(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), }; @@ -1430,6 +1453,7 @@ mod tests { let cache = AnalysisCache::open_in_memory(); let g = resolve_loudness_gain_with_cache( &cache, + "", "no-such-track", -14.0, ResolveLoudnessCacheOpts::default(), @@ -1444,6 +1468,7 @@ mod tests { upsert_loudness_row(&cache, "abc", -23.0, -14.0); let g = resolve_loudness_gain_with_cache( &cache, + "", "abc", -14.0, ResolveLoudnessCacheOpts::default(), @@ -1468,6 +1493,7 @@ mod tests { upsert_loudness_row(&cache, "stream:abc", -16.0, -14.0); let g = resolve_loudness_gain_with_cache( &cache, + "", "abc", -14.0, ResolveLoudnessCacheOpts::default(), @@ -1481,6 +1507,7 @@ mod tests { upsert_loudness_row(&cache, "abc", -20.0, -14.0); let g_quiet = resolve_loudness_gain_with_cache( &cache, + "", "abc", -20.0, ResolveLoudnessCacheOpts::default(), @@ -1488,6 +1515,7 @@ mod tests { .unwrap(); let g_loud = resolve_loudness_gain_with_cache( &cache, + "", "abc", -10.0, ResolveLoudnessCacheOpts::default(), @@ -1508,7 +1536,7 @@ mod tests { touch_waveform: false, log_soft_misses: false, }; - let g = resolve_loudness_gain_with_cache(&cache, "abc", -14.0, opts); + let g = resolve_loudness_gain_with_cache(&cache, "", "abc", -14.0, opts); assert!(g.is_some()); } } diff --git a/src-tauri/crates/psysonic-audio/src/mix_commands.rs b/src-tauri/crates/psysonic-audio/src/mix_commands.rs index 909a82ce..7acf245f 100644 --- a/src-tauri/crates/psysonic-audio/src/mix_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/mix_commands.rs @@ -50,12 +50,14 @@ pub fn audio_update_replay_gain( .filter(|s| !s.is_empty()); // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` // for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`). + let server_for_loudness = crate::helpers::current_playback_server_id_str(&state); let cache_loudness = url_for_loudness.as_deref().and_then(|u| { resolve_loudness_gain_from_cache_impl( &app, u, target_lufs, logical_for_loudness.as_deref(), + &server_for_loudness, ResolveLoudnessCacheOpts { touch_waveform: false, log_soft_misses: false, diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index 2dbff9fc..ee088141 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -54,6 +54,9 @@ pub(super) struct PlayInputContext<'a> { pub stream_format_suffix: Option<&'a str>, pub format_hint: Option<&'a str>, pub cache_id_for_tasks: Option<&'a str>, + /// Playback server scope for the analysis-cache write key (empty/`None` → + /// legacy `''`). Rides alongside `cache_id_for_tasks` into every seed path. + pub server_id: Option<&'a str>, /// `Some(bytes)` when manual-skip onto a pre-chained track reuses bytes /// from the chained-info block. pub reuse_chained_bytes: Option>, @@ -76,6 +79,7 @@ pub(super) async fn select_play_input( if let Some(d) = ctx.reuse_chained_bytes { spawn_analysis_seed_from_in_memory_bytes( app, + ctx.server_id, ctx.cache_id_for_tasks, ctx.gen, &state.generation, @@ -112,6 +116,7 @@ pub(super) async fn select_play_input( }; spawn_analysis_seed_from_in_memory_bytes( app, + ctx.server_id, ctx.cache_id_for_tasks, ctx.gen, &state.generation, @@ -143,7 +148,10 @@ fn open_local_file_input( if let Some(seed_id) = ctx.cache_id_for_tasks { let skip_cpu_seed = app .try_state::() - .map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false)) + .map(|c| { + c.cpu_seed_redundant_for_track(ctx.server_id.unwrap_or(""), seed_id) + .unwrap_or(false) + }) .unwrap_or(false); if !skip_cpu_seed { let path_owned = std::path::PathBuf::from(path); @@ -151,6 +159,7 @@ fn open_local_file_input( let gen_seed = ctx.gen; let gen_arc_seed = state.generation.clone(); let seed_id = seed_id.to_string(); + let seed_server = ctx.server_id.unwrap_or("").to_string(); tokio::spawn(async move { if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { return; @@ -178,7 +187,7 @@ fn open_local_file_input( ); let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id); if let Err(e) = - psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await + psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_server.clone(), seed_id.clone(), data, high).await { crate::app_eprintln!( "[analysis] local-file seed failed for {}: {}", @@ -316,6 +325,7 @@ async fn open_ranged_or_streaming_input( state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), ctx.cache_id_for_tasks.map(|s| s.to_string()), + ctx.server_id.map(|s| s.to_string()), loudness_hold_for_defer, playback_armed, stream_hint.clone(), @@ -369,6 +379,7 @@ async fn open_ranged_or_streaming_input( state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), ctx.cache_id_for_tasks.map(|s| s.to_string()), + ctx.server_id.map(|s| s.to_string()), playback_armed, )); @@ -459,6 +470,7 @@ pub(crate) struct BuildSourceArgs<'a> { pub url: &'a str, pub gen: u64, pub cache_id_for_tasks: Option<&'a str>, + pub server_id: Option<&'a str>, pub url_format_hint: Option<&'a str>, pub stream_format_suffix: Option<&'a str>, pub done_flag: Arc, @@ -686,6 +698,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback( url, gen, cache_id_for_tasks, + server_id, url_format_hint, stream_format_suffix, done_flag, @@ -751,6 +764,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback( } spawn_analysis_seed_from_in_memory_bytes( app, + server_id, cache_id_for_tasks, gen, &state.generation, diff --git a/src-tauri/crates/psysonic-audio/src/preload_commands.rs b/src-tauri/crates/psysonic-audio/src/preload_commands.rs index 097f2198..c700389b 100644 --- a/src-tauri/crates/psysonic-audio/src/preload_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/preload_commands.rs @@ -17,6 +17,7 @@ pub async fn audio_preload( url: String, duration_hint: f64, analysis_track_id: Option, + server_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -56,7 +57,8 @@ pub async fn audio_preload( data.len() as f64 / (1024.0 * 1024.0) ); let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id); - if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await { + let sid = server_id.clone().unwrap_or_default(); + if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await { crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e); } } diff --git a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs index 06ecbef8..9b7f313d 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs @@ -460,6 +460,8 @@ pub(crate) async fn ranged_download_task( normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, + // Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). + server_id: Option, // When `Some`, ranged playback seeds on completion — defer HTTP backfill for that // track; `None` for large files where ranged skips seed (needs backfill). loudness_seed_hold: Option, @@ -643,7 +645,8 @@ pub(crate) async fn ranged_download_task( } if let Some(track_id) = cache_track_id { let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id); - if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await { + let sid = server_id.clone().unwrap_or_default(); + if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await { crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e); } } @@ -678,6 +681,7 @@ pub(crate) async fn ranged_download_task( install_stream_completed_spill(&spill_cache_slot, url, path.clone()); spawn_analysis_seed_from_spill_file( &app, + server_id.as_deref(), &track_id, path, gen, diff --git a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs index bc220e09..0533fff3 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs @@ -35,6 +35,8 @@ pub(crate) async fn track_download_task( normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, + // Playback server scope for the analysis-cache write key (empty/`None` → legacy ''). + server_id: Option, playback_armed: Arc, ) { let mut downloaded: u64 = 0; @@ -166,8 +168,9 @@ pub(crate) async fn track_download_task( capture.len() as f64 / (1024.0 * 1024.0) ); let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id); + let sid = server_id.clone().unwrap_or_default(); if let Err(e) = - psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await + psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), capture.clone(), high).await { crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e); } diff --git a/src-tauri/crates/psysonic-audio/src/transport_commands.rs b/src-tauri/crates/psysonic-audio/src/transport_commands.rs index a56a26bc..81a751c0 100644 --- a/src-tauri/crates/psysonic-audio/src/transport_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/transport_commands.rs @@ -108,6 +108,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) { state.generation.fetch_add(1, Ordering::SeqCst); *state.current_playback_url.lock().unwrap() = None; *state.current_analysis_track_id.lock().unwrap() = None; + *state.current_playback_server_id.lock().unwrap() = None; *state.chained_info.lock().unwrap() = None; // Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the // queue is exhausted; clearing here dropped the full ranged buffer and forced a diff --git a/src-tauri/crates/psysonic-core/src/ports.rs b/src-tauri/crates/psysonic-core/src/ports.rs index fc4f0610..a49ad09c 100644 --- a/src-tauri/crates/psysonic-core/src/ports.rs +++ b/src-tauri/crates/psysonic-core/src/ports.rs @@ -49,3 +49,63 @@ impl PlaybackQueryHandle { (self.should_defer_backfill)(track_id) } } + +/// Bridge for the analysis→library back-edge (E2 content_hash): when the +/// analysis pipeline has the playback-derived `md5_16kb` for a track, it records +/// it as `track.content_hash` in the library DB. `psysonic-analysis` must not +/// depend on `psysonic-library`, so the shell crate registers a closure that +/// captures an `AppHandle` and patches the library; analysis looks this handle +/// up via `try_state::<…>()` and fires it after a successful seed. +/// +/// The patch is a no-op when the library has no row for `(server_id, track_id)` +/// (index off for that server), so the sink is safe to call unconditionally. +type RecordContentHashFn = Arc; + +#[derive(Clone)] +pub struct ContentHashSink { + record: RecordContentHashFn, +} + +impl ContentHashSink { + pub fn new(record: F) -> Self + where + F: Fn(&str, &str, &str) + Send + Sync + 'static, + { + Self { record: Arc::new(record) } + } + + /// Record `md5_16kb` as the library `content_hash` for `(server_id, track_id)`. + /// Best-effort: the registered closure swallows errors and no-ops when the + /// library has no matching row. + pub fn record_content_hash(&self, server_id: &str, track_id: &str, md5_16kb: &str) { + (self.record)(server_id, track_id, md5_16kb) + } +} + +/// Library→analysis readiness probe (E3 enrichment): given `(server_id, +/// track_id, md5_16kb)`, returns `(waveform_ready, loudness_ready)` from the +/// analysis cache. `psysonic-library` must not depend on `psysonic-analysis`, so +/// the shell crate registers a closure that captures an `AppHandle`, looks up the +/// `AnalysisCache`, and probes the exact key with a legacy `''` fallback — +/// **read-only, no lazy re-tag**. Library looks this handle up via +/// `try_state::<…>()`; absent handle ⇒ `(false, false)`. +type QueryReadinessFn = Arc (bool, bool) + Send + Sync + 'static>; + +#[derive(Clone)] +pub struct AnalysisReadinessQuery { + query: QueryReadinessFn, +} + +impl AnalysisReadinessQuery { + pub fn new(query: F) -> Self + where + F: Fn(&str, &str, &str) -> (bool, bool) + Send + Sync + 'static, + { + Self { query: Arc::new(query) } + } + + /// `(waveform_ready, loudness_ready)` for `(server_id, track_id, md5_16kb)`. + pub fn readiness(&self, server_id: &str, track_id: &str, md5_16kb: &str) -> (bool, bool) { + (self.query)(server_id, track_id, md5_16kb) + } +} diff --git a/src-tauri/crates/psysonic-integration/Cargo.toml b/src-tauri/crates/psysonic-integration/Cargo.toml index f4bc5715..62553ecb 100644 --- a/src-tauri/crates/psysonic-integration/Cargo.toml +++ b/src-tauri/crates/psysonic-integration/Cargo.toml @@ -12,7 +12,7 @@ tauri = { version = "2" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt", "time", "sync"] } -reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] } +reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] } futures-util = "0.3" discord-rich-presence = "1.1" url = "2" diff --git a/src-tauri/crates/psysonic-integration/src/lib.rs b/src-tauri/crates/psysonic-integration/src/lib.rs index 3182b29c..0047d73a 100644 --- a/src-tauri/crates/psysonic-integration/src/lib.rs +++ b/src-tauri/crates/psysonic-integration/src/lib.rs @@ -4,6 +4,7 @@ //! Domains: //! - `discord` — Discord Rich Presence (album artwork via iTunes) //! - `navidrome` — Navidrome's native REST API (admin: users/playlists/covers/queries) +//! - `subsonic` — Subsonic REST surface for the library-sync engine //! - `remote` — radio-browser, last.fm, ICY-meta probe, generic CORS proxy //! - `bandsintown` — bandsintown events for an artist @@ -15,3 +16,4 @@ pub mod bandsintown; pub mod discord; pub mod navidrome; pub mod remote; +pub mod subsonic; diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs b/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs index d838a950..72d1b096 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/mod.rs @@ -7,5 +7,8 @@ mod client; pub mod covers; pub mod playlists; +pub mod probe; pub mod queries; pub mod users; + +pub use client::navidrome_token; diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs b/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs new file mode 100644 index 00000000..687a7bb3 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/navidrome/probe.rs @@ -0,0 +1,116 @@ +//! Navidrome-side probes for the library-sync capability detection. +//! +//! Lives next to `client.rs` / `queries.rs` so the existing native-REST +//! auth shape (`Authorization: Bearer …`) is reused. PR-3a only needs +//! one probe — does the server expose the paginated `/api/song` bulk +//! endpoint? — so this stays a free function rather than a client +//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b. + +use super::client::{nd_err, nd_http_client}; + +/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with +/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or +/// disabled) and 5xx surfaces as `Err`. The body is intentionally not +/// inspected — empty libraries still respond with `[]` and a 200. +/// +/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability +/// flag. Wider call into the actual ingest path (`nd_list_songs` port) +/// is PR-3b's job. +pub async fn native_bulk_available(server_url: &str, token: &str) -> Result { + let client = nd_http_client(); + let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/')); + let resp = client + .get(url) + .header("X-ND-Authorization", format!("Bearer {token}")) + .send() + .await + .map_err(nd_err)?; + + let status = resp.status(); + if status.is_success() { + return Ok(true); + } + if status.is_client_error() { + // 401/403/404 — endpoint genuinely unavailable for this token / + // build. Treat as "no native bulk" and fall back to Subsonic. + return Ok(false); + } + Err(format!("HTTP {status}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_returns_true_on_200() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "0")) + .and(query_param("_end", "1")) + .and(header("X-ND-Authorization", "Bearer tok-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(&server) + .await; + + let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap(); + assert!(ok); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_returns_false_on_404() { + // Server is reachable, auth might be ok, but the endpoint just + // doesn't exist (older Navidrome, mod_rewrite mishap, …). + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let ok = native_bulk_available(&server.uri(), "tok").await.unwrap(); + assert!(!ok); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_returns_false_on_401_auth_failure() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let ok = native_bulk_available(&server.uri(), "bad").await.unwrap(); + assert!(!ok, "401 reads as `endpoint not available for this caller`"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_surfaces_5xx_as_error() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err(); + assert!(err.contains("503")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_bulk_available_strips_trailing_slash() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(&server) + .await; + + let with_slash = format!("{}/", server.uri()); + assert!(native_bulk_available(&with_slash, "tok").await.unwrap()); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs index fb4116f3..934f239f 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs @@ -4,14 +4,15 @@ use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry}; -/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. -/// Available to any authenticated user (no admin required). Returns raw JSON array. -#[tauri::command] -pub async fn nd_list_songs( - server_url: String, - token: String, - sort: String, - order: String, +/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated +/// song list. Pure async helper used by the library-side N1 ingest +/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]` +/// variant below for existing frontend callers. +pub async fn nd_list_songs_internal( + server_url: &str, + token: &str, + sort: &str, + order: &str, start: u32, end: u32, ) -> Result { @@ -22,7 +23,7 @@ pub async fn nd_list_songs( let resp = nd_retry(|| { nd_http_client() .get(&url) - .header("X-ND-Authorization", format!("Bearer {}", token)) + .header("X-ND-Authorization", format!("Bearer {token}")) .send() }).await?; if !resp.status().is_success() { @@ -31,6 +32,20 @@ pub async fn nd_list_songs( resp.json::().await.map_err(nd_err) } +/// Tauri-visible variant — owned-String arguments to keep the IPC +/// surface unchanged for existing call sites in the WebView. +#[tauri::command] +pub async fn nd_list_songs( + server_url: String, + token: String, + sort: String, + order: String, + start: u32, + end: u32, +) -> Result { + nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await +} + /// Build the `_filters` JSON for native-API list calls. Optionally narrows the /// query to a single library — `library_id` is the same scope key the Navidrome /// web UI sends, and it matches the Subsonic `musicFolderId` we store per server. diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/auth.rs b/src-tauri/crates/psysonic-integration/src/subsonic/auth.rs new file mode 100644 index 00000000..c112a881 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/auth.rs @@ -0,0 +1,91 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Subsonic credentials in the legacy salted-md5 shape (spec v1.13+): +/// the client sends `u` + `t = md5(password || salt)` + `s = salt`, +/// never the plaintext password. The salt is per-request to defeat +/// replay, but doesn't need to be cryptographically random — Subsonic's +/// API treats it as an opaque uniqueness nonce. +#[derive(Debug, Clone)] +pub struct SubsonicCredentials { + pub username: String, + pub token: String, + pub salt: String, +} + +impl SubsonicCredentials { + /// Derive a credentials triple from a plaintext password. Generates a + /// fresh salt and computes `md5(password || salt)`. + pub fn from_password(username: impl Into, password: &str) -> Self { + let salt = fresh_salt(); + let token = md5_hex(&format!("{password}{salt}")); + Self { username: username.into(), token, salt } + } + + /// Use a caller-supplied salt + token. Intended for tests and for + /// callers that already cache the derivation result. + pub fn with_static(username: impl Into, token: impl Into, salt: impl Into) -> Self { + Self { username: username.into(), token: token.into(), salt: salt.into() } + } +} + +/// Per-process monotonically-advancing nonce mixed into the salt so the +/// hot loop of `from_password` calls doesn't repeat itself even at the +/// same nanosecond. +static SALT_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn fresh_salt() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let counter = SALT_COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id() as u64; + format!("{:016x}{:08x}", nanos ^ pid.rotate_left(13), counter) +} + +fn md5_hex(input: &str) -> String { + let digest = md5::compute(input.as_bytes()); + format!("{digest:x}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn from_password_computes_md5_token() { + // Force the salt by going through with_static for a deterministic check. + let salt = "abc123"; + let password = "sesame"; + let expected = md5_hex(&format!("{password}{salt}")); + let creds = SubsonicCredentials::with_static("user", &expected, salt); + assert_eq!(creds.token, expected); + assert_eq!(creds.token.len(), 32, "md5 hex must be 32 chars"); + } + + #[test] + fn fresh_salt_is_unique_across_rapid_calls() { + let mut seen: HashSet = HashSet::new(); + for _ in 0..1000 { + assert!(seen.insert(fresh_salt()), "fresh_salt repeated"); + } + } + + #[test] + fn from_password_produces_different_salts_per_call() { + let a = SubsonicCredentials::from_password("u", "pw"); + let b = SubsonicCredentials::from_password("u", "pw"); + assert_ne!(a.salt, b.salt); + assert_ne!(a.token, b.token, "different salt → different token"); + } + + #[test] + fn md5_hex_matches_known_vector() { + // md5("") = d41d8cd98f00b204e9800998ecf8427e + assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e"); + // md5("abc") = 900150983cd24fb0d6963f7d28e17f72 + assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72"); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/client.rs b/src-tauri/crates/psysonic-integration/src/subsonic/client.rs new file mode 100644 index 00000000..d4104bb0 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/client.rs @@ -0,0 +1,1172 @@ +//! `SubsonicClient` — read-only Subsonic REST surface needed by the +//! library-sync engine (phase B per spec §10 / PR-2). Auth is the legacy +//! salted-md5 token (spec v1.13+); request shape is GET to +//! `{base}/rest/{method}.view?u&t&s&v&c&f=json&…`. +//! +//! This client is pure Rust — **no `#[tauri::command]`**. Tauri commands +//! that talk to the library live in PR-5 / phase D. + +use serde::de::DeserializeOwned; +use serde::Deserialize; + +use super::auth::SubsonicCredentials; +use super::error::{flatten_reqwest_error, SubsonicError}; +use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song}; + +/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that +/// Navidrome and other servers in the wild support. OpenSubsonic +/// extensions deserialize when present (additive on the wire). +pub const SUBSONIC_API_VERSION: &str = "1.16.1"; + +/// Subsonic `c` parameter — server logs and rate-limiters key off this. +/// Matches the frontend `subsonicClient.ts` shape (`psysonic/`) +/// so Navidrome log lines correlate across the WebView and Rust sync +/// paths. +pub const SUBSONIC_CLIENT_ID: &str = concat!("psysonic/", env!("CARGO_PKG_VERSION")); + +#[derive(Clone)] +enum CredentialsMode { + /// Production path: cache the plaintext password and derive a fresh + /// `(token = md5(password || salt), salt)` per request. Matches the + /// frontend's `getAuthParams()` lifecycle and follows Subsonic + /// replay-resistance guidance. + FromPassword { username: String, password: String }, + /// Test path: re-use a pre-derived credentials triple as-is. Used by + /// wiremock tests (deterministic query params) and by callers that + /// already maintain a cached token+salt. + Static(SubsonicCredentials), +} + +#[derive(Clone)] +pub struct SubsonicClient { + base_url: String, + credentials: CredentialsMode, + http: reqwest::Client, +} + +impl SubsonicClient { + /// Production constructor — caches the password and derives a fresh + /// salt + token on every API call. + pub fn new( + base_url: impl Into, + username: impl Into, + password: impl Into, + ) -> Self { + Self::with_http(base_url, username, password, default_http_client()) + } + + /// As `new`, but with a caller-supplied `reqwest::Client` — used by + /// callers that share a pool across multiple Subsonic servers or + /// need custom timeouts. + pub fn with_http( + base_url: impl Into, + username: impl Into, + password: impl Into, + http: reqwest::Client, + ) -> Self { + let mut url = base_url.into(); + while url.ends_with('/') { + url.pop(); + } + Self { + base_url: url, + credentials: CredentialsMode::FromPassword { + username: username.into(), + password: password.into(), + }, + http, + } + } + + /// Test-/cache-friendly constructor — re-uses the same + /// `SubsonicCredentials` triple on every call. Wiremock tests rely on + /// this for deterministic `s=` and `t=` query params; production code + /// goes through `new` / `with_http`. + pub fn with_static_credentials( + base_url: impl Into, + credentials: SubsonicCredentials, + http: reqwest::Client, + ) -> Self { + let mut url = base_url.into(); + while url.ends_with('/') { + url.pop(); + } + Self { + base_url: url, + credentials: CredentialsMode::Static(credentials), + http, + } + } + + pub(crate) fn build_credentials(&self) -> SubsonicCredentials { + match &self.credentials { + CredentialsMode::FromPassword { username, password } => { + SubsonicCredentials::from_password(username, password) + } + CredentialsMode::Static(c) => c.clone(), + } + } + + /// B1 — ping. Returns `Ok(())` when the server replied with + /// `status="ok"`; surfaces `SubsonicError::Api{40,…}` for invalid + /// credentials and the usual transport / status errors otherwise. + pub async fn ping(&self) -> Result<(), SubsonicError> { + let body = self.send("ping", &[]).await?; + parse_envelope_status_only(&body) + } + + /// C1 helper — `#ping` with the envelope metadata captured. Used by + /// the capability probe to detect server type (`navidrome` → + /// `UnstableTrackIds`) and OpenSubsonic support without issuing a + /// second request. + pub async fn server_info(&self) -> Result { + let body = self.send("ping", &[]).await?; + parse_server_info(&body) + } + + /// B2 — `getScanStatus`. Lightweight poll for huge libraries + /// (spec §2.3 / §6.2.2). + pub async fn get_scan_status(&self) -> Result { + self.fetch("getScanStatus", &[], "scanStatus").await + } + + /// B5 — `getIndexes(musicFolderId?, ifModifiedSince?)`. File-tree + /// browse with conditional fetch — when `ifModifiedSince` matches the + /// server's `lastScan`, the response body is empty but the + /// `lastModified` watermark is still returned (spec §3.1). + pub async fn get_indexes( + &self, + music_folder_id: Option<&str>, + if_modified_since_ms: Option, + ) -> Result { + let ims = if_modified_since_ms.map(|n| n.to_string()); + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + if let Some(ref s) = ims { + params.push(("ifModifiedSince", s)); + } + self.fetch("getIndexes", ¶ms, "indexes").await + } + + /// B8 — `getArtists(musicFolderId?)`. ID3-path artist index. Always + /// returns full body; clients compare `last_modified_ms` against the + /// watermark in `sync_state` to decide whether a delta pass is needed + /// (spec §2.2.1). + pub async fn get_artists( + &self, + music_folder_id: Option<&str>, + ) -> Result { + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + self.fetch("getArtists", ¶ms, "artists").await + } + + /// B3a — `getAlbumList2(type, size, offset, musicFolderId?)`. Returns + /// just the album summaries; the caller follows up with `get_album` + /// per id to enumerate songs. + pub async fn get_album_list2( + &self, + list_type: &str, + size: u32, + offset: u32, + music_folder_id: Option<&str>, + ) -> Result, SubsonicError> { + let size_s = size.to_string(); + let offset_s = offset.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("type", list_type), + ("size", size_s.as_str()), + ("offset", offset_s.as_str()), + ]; + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + let wrapped: AlbumListWrapper = + self.fetch("getAlbumList2", ¶ms, "albumList2").await?; + Ok(wrapped.album) + } + + /// B3b — `getAlbum(id)`. Returns the album metadata plus the full song list. + pub async fn get_album(&self, album_id: &str) -> Result { + self.fetch("getAlbum", &[("id", album_id)], "album").await + } + + /// B4 — `search3(query, songCount, songOffset, musicFolderId?)`. + /// Navidrome accepts an empty query and returns all songs paged — + /// spec §2.4 documents that quirk and Psysonic already relies on it. + pub async fn search3( + &self, + query: &str, + song_count: u32, + song_offset: u32, + music_folder_id: Option<&str>, + ) -> Result { + let song_count_s = song_count.to_string(); + let song_offset_s = song_offset.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("query", query), + ("songCount", song_count_s.as_str()), + ("songOffset", song_offset_s.as_str()), + ]; + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + self.fetch("search3", ¶ms, "searchResult3").await + } + + /// Variant of `search3` returning the raw `serde_json::Value` for + /// the `searchResult3` body alongside the typed projection. The S1 + /// ingest path (PR-3b InitialSyncRunner) needs the per-song raw + /// sub-trees verbatim for `track.raw_json`, so unknown OpenSubsonic + /// extensions (`replayGain`, `contributors`, …) survive ingest + /// instead of being lost in the typed reserialise (ADR-7). + pub async fn search3_with_raw( + &self, + query: &str, + song_count: u32, + song_offset: u32, + music_folder_id: Option<&str>, + ) -> Result<(SearchResult, serde_json::Value), SubsonicError> { + let song_count_s = song_count.to_string(); + let song_offset_s = song_offset.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("query", query), + ("songCount", song_count_s.as_str()), + ("songOffset", song_offset_s.as_str()), + ]; + if let Some(id) = music_folder_id { + params.push(("musicFolderId", id)); + } + let body = self.send("search3", ¶ms).await?; + parse_envelope_with_raw(&body, "searchResult3") + } + + /// B6 — `getSong(id)`. Returns `SubsonicError::NotFound` when the + /// server replies with error code 70 (spec §2.6) — the tombstone + /// reconciler matches on that variant directly. + pub async fn get_song(&self, song_id: &str) -> Result { + self.fetch("getSong", &[("id", song_id)], "song").await + } + + /// Variant of `get_song` that also returns the raw `serde_json::Value` + /// the server sent for the `song` body. The sync engine (PR-3) stores + /// that raw object verbatim in `track.raw_json` so OpenSubsonic + /// extensions (`contributors`, `replayGain`, future fields) survive + /// without being mirrored into the typed `Song` struct. + pub async fn get_song_with_raw( + &self, + song_id: &str, + ) -> Result<(Song, serde_json::Value), SubsonicError> { + let body = self.send("getSong", &[("id", song_id)]).await?; + parse_envelope_with_raw(&body, "song") + } + + /// Variant of `get_album` returning the raw `serde_json::Value` for + /// the `album` body alongside the typed projection. The album JSON + /// already nests the full song list, so the sync engine can derive + /// per-track `raw_json` cells (each entry in `album.song`) without + /// issuing follow-up `get_song` calls. + pub async fn get_album_with_raw( + &self, + album_id: &str, + ) -> Result<(Album, serde_json::Value), SubsonicError> { + let body = self.send("getAlbum", &[("id", album_id)]).await?; + parse_envelope_with_raw(&body, "album") + } + + async fn fetch( + &self, + method: &str, + extra: &[(&str, &str)], + body_key: &str, + ) -> Result { + let body = self.send(method, extra).await?; + parse_envelope(&body, body_key) + } + + async fn send(&self, method: &str, extra: &[(&str, &str)]) -> Result { + let creds = self.build_credentials(); + let auth = [ + ("u", creds.username.as_str()), + ("t", creds.token.as_str()), + ("s", creds.salt.as_str()), + ("v", SUBSONIC_API_VERSION), + ("c", SUBSONIC_CLIENT_ID), + ("f", "json"), + ]; + let mut query: Vec<(&str, &str)> = auth.to_vec(); + query.extend_from_slice(extra); + + let resp = self + .http + .get(format!("{}/rest/{method}.view", self.base_url)) + .query(&query) + .send() + .await + .map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?; + + if !resp.status().is_success() { + return Err(SubsonicError::HttpStatus(resp.status())); + } + resp.text() + .await + .map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e))) + } +} + +#[derive(Deserialize)] +struct AlbumListWrapper { + #[serde(default)] + album: Vec, +} + +fn default_http_client() -> reqwest::Client { + reqwest::Client::builder() + .user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION"))) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Validate the Subsonic envelope and return the raw `serde_json::Value` +/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound` +/// variant; surfaces every other failed status as `Api { code, message }`. +/// Callers either deserialize the value into a typed struct +/// (`parse_envelope`) or keep both alongside (`parse_envelope_with_raw`). +fn parse_envelope_body(body: &str, body_key: &str) -> Result { + let envelope: serde_json::Value = serde_json::from_str(body) + .map_err(|e| SubsonicError::Decode(format!("envelope: {e}")))?; + let response = envelope + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("missing `subsonic-response`".into()))?; + + if let Some(err) = response.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + return Err(map_error(code, message)); + } + + let status = response.get("status").and_then(|s| s.as_str()).unwrap_or_default(); + if status != "ok" { + return Err(SubsonicError::Decode(format!("unexpected status `{status}`"))); + } + + response + .get(body_key) + .cloned() + .ok_or_else(|| SubsonicError::Decode(format!("missing body key `{body_key}`"))) +} + +/// Validate the envelope, then deserialize the body into `T`. +fn parse_envelope(body: &str, body_key: &str) -> Result { + let body_val = parse_envelope_body(body, body_key)?; + serde_json::from_value(body_val) + .map_err(|e| SubsonicError::Decode(format!("body `{body_key}`: {e}"))) +} + +/// Validate the envelope, then return both the typed projection and the +/// raw `serde_json::Value` body sub-tree. PR-3 sync code uses this to +/// keep `track.raw_json` intact while still operating on a typed `Song` +/// at the call site. +fn parse_envelope_with_raw( + body: &str, + body_key: &str, +) -> Result<(T, serde_json::Value), SubsonicError> { + let body_val = parse_envelope_body(body, body_key)?; + let typed = serde_json::from_value(body_val.clone()) + .map_err(|e| SubsonicError::Decode(format!("body `{body_key}`: {e}")))?; + Ok((typed, body_val)) +} + +/// Variant of `parse_envelope` for endpoints that carry no body (only +/// `ping` in PR-2). Returns `Ok(())` when `status="ok"` and falls back to +/// the same error mapping. +fn parse_envelope_status_only(body: &str) -> Result<(), SubsonicError> { + let envelope: serde_json::Value = serde_json::from_str(body) + .map_err(|e| SubsonicError::Decode(format!("envelope: {e}")))?; + let response = envelope + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("missing `subsonic-response`".into()))?; + + if let Some(err) = response.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + return Err(map_error(code, message)); + } + + let status = response.get("status").and_then(|s| s.as_str()).unwrap_or_default(); + match status { + "ok" => Ok(()), + other => Err(SubsonicError::Decode(format!("unexpected status `{other}`"))), + } +} + +fn map_error(code: i32, message: String) -> SubsonicError { + if code == 70 { + SubsonicError::NotFound + } else { + SubsonicError::Api { code, message } + } +} + +/// Inspect the `subsonic-response` envelope itself for server metadata. +/// Used by `server_info()` and by the capability probe. +fn parse_server_info(body: &str) -> Result { + let envelope: serde_json::Value = serde_json::from_str(body) + .map_err(|e| SubsonicError::Decode(format!("envelope: {e}")))?; + let response = envelope + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("missing `subsonic-response`".into()))?; + + if let Some(err) = response.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32; + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + return Err(map_error(code, message)); + } + + let status = response.get("status").and_then(|s| s.as_str()).unwrap_or_default(); + if status != "ok" { + return Err(SubsonicError::Decode(format!("unexpected status `{status}`"))); + } + + Ok(ServerInfo { + server_type: response.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()), + server_version: response + .get("serverVersion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + api_version: response.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()), + open_subsonic: response.get("openSubsonic").and_then(|v| v.as_bool()).unwrap_or(false), + }) +} + +/// B9 — pick every `every_n`-th id from a sorted list. Used by the +/// server-fingerprint pass on re-add: 1 % of cached track ids are +/// probed via `get_song` and any 404 (`NotFound`) means the server's +/// id space drifted (spec §5.6, P19). The actual probing + comparison +/// is glue code in `psysonic-library` (PR-3 territory); this crate +/// just ships the deterministic sampling primitive so both sides use +/// the same selection logic. +pub fn fingerprint_sample(track_ids: &[String], every_n: usize) -> Vec<&String> { + if every_n == 0 { + return Vec::new(); + } + track_ids + .iter() + .enumerate() + .filter(|(i, _)| i % every_n == 0) + .map(|(_, id)| id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // ── parse_envelope unit tests (no HTTP) ──────────────────────────────── + + #[test] + fn parse_envelope_extracts_body_on_ok_status() { + let body = json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "scanStatus": { + "scanning": false, + "count": 42 + } + } + }) + .to_string(); + let s: ScanStatus = parse_envelope(&body, "scanStatus").unwrap(); + assert_eq!(s.count, Some(42)); + } + + #[test] + fn parse_envelope_maps_code_70_to_not_found() { + let body = json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }) + .to_string(); + let err = parse_envelope::(&body, "song").unwrap_err(); + assert!(matches!(err, SubsonicError::NotFound)); + } + + #[test] + fn parse_envelope_surfaces_other_error_codes_as_api_variant() { + let body = json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong username or password" } + } + }) + .to_string(); + let err = parse_envelope::(&body, "song").unwrap_err(); + match err { + SubsonicError::Api { code, message } => { + assert_eq!(code, 40); + assert!(message.contains("Wrong")); + } + other => panic!("expected Api, got {other:?}"), + } + } + + #[test] + fn parse_envelope_rejects_missing_body_key() { + let body = json!({ + "subsonic-response": { "status": "ok" } + }) + .to_string(); + let err = parse_envelope::(&body, "song").unwrap_err(); + assert!(matches!(err, SubsonicError::Decode(_))); + } + + #[test] + fn parse_envelope_status_only_accepts_empty_ok() { + let body = json!({ "subsonic-response": { "status": "ok", "version": "1.16.1" } }).to_string(); + parse_envelope_status_only(&body).unwrap(); + } + + // ── fingerprint_sample ──────────────────────────────────────────────── + + #[test] + fn fingerprint_sample_picks_every_nth_id() { + let ids: Vec = (0..10).map(|i| format!("tr_{i}")).collect(); + let sample = fingerprint_sample(&ids, 4); + assert_eq!( + sample.iter().map(|s| s.as_str()).collect::>(), + vec!["tr_0", "tr_4", "tr_8"] + ); + } + + #[test] + fn fingerprint_sample_is_deterministic_across_runs() { + let ids: Vec = (0..500).map(|i| format!("tr_{i:04}")).collect(); + let a = fingerprint_sample(&ids, 100); + let b = fingerprint_sample(&ids, 100); + assert_eq!(a, b); + assert_eq!(a.len(), 5, "500/100 = 5 samples"); + } + + #[test] + fn fingerprint_sample_zero_n_is_empty() { + let ids: Vec = vec!["a".into(), "b".into()]; + assert!(fingerprint_sample(&ids, 0).is_empty()); + } + + // ── SubsonicClient wiremock end-to-end ──────────────────────────────── + + fn test_credentials() -> SubsonicCredentials { + SubsonicCredentials::with_static("user", "deadbeef", "saltsalt") + } + + fn test_client(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials(uri, test_credentials(), reqwest::Client::new()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn ping_sends_auth_params_and_returns_ok() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .and(query_param("u", "user")) + .and(query_param("t", "deadbeef")) + .and(query_param("s", "saltsalt")) + .and(query_param("v", SUBSONIC_API_VERSION)) + .and(query_param("c", SUBSONIC_CLIENT_ID)) + .and(query_param("f", "json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.16.1" } + }))) + .mount(&server) + .await; + + test_client(&server.uri()).ping().await.expect("ping must succeed"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn ping_surfaces_wrong_credentials_as_code_40() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong username or password" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()).ping().await.unwrap_err(); + assert!(matches!(err, SubsonicError::Api { code: 40, .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_returns_typed_song() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { + "id": "tr_1", + "title": "Aurora", + "artist": "Anna", + "albumId": "al_1", + "duration": 240, + "track": 3 + } + } + }))) + .mount(&server) + .await; + + let song = test_client(&server.uri()).get_song("tr_1").await.unwrap(); + assert_eq!(song.title, "Aurora"); + assert_eq!(song.album_id.as_deref(), Some("al_1")); + assert_eq!(song.track_number, Some(3)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_maps_error_70_to_not_found() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()).get_song("missing").await.unwrap_err(); + assert!(matches!(err, SubsonicError::NotFound)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_scan_status_parses_typed_struct() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "scanStatus": { + "scanning": true, + "count": 9001, + "folderCount": 12 + } + } + }))) + .mount(&server) + .await; + + let s = test_client(&server.uri()).get_scan_status().await.unwrap(); + assert!(s.scanning); + assert_eq!(s.count, Some(9001)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_indexes_forwards_optional_if_modified_since() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .and(query_param("ifModifiedSince", "1716840000000")) + .and(query_param("musicFolderId", "lib-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "indexes": { + "lastModified": 1716840000000_i64, + "ignoredArticles": "The", + "index": [] + } + } + }))) + .mount(&server) + .await; + + let ix = test_client(&server.uri()) + .get_indexes(Some("lib-1"), Some(1_716_840_000_000)) + .await + .unwrap(); + assert_eq!(ix.last_modified_ms, Some(1_716_840_000_000)); + assert!(ix.index.is_empty(), "empty body when nothing changed"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_artists_omits_music_folder_when_none() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1716840000000_i64, + "ignoredArticles": "", + "index": [ + { "name": "A", "artist": [ + { "id": "ar_1", "name": "Anna" } + ]} + ] + } + } + }))) + .mount(&server) + .await; + + let ix = test_client(&server.uri()).get_artists(None).await.unwrap(); + assert_eq!(ix.index.len(), 1); + assert_eq!(ix.index[0].artist[0].name, "Anna"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_album_list2_unwraps_album_array() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("type", "alphabeticalByName")) + .and(query_param("size", "500")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { + "album": [ + { "id": "al_1", "name": "First" }, + { "id": "al_2", "name": "Second" } + ] + } + } + }))) + .mount(&server) + .await; + + let albums = test_client(&server.uri()) + .get_album_list2("alphabeticalByName", 500, 0, None) + .await + .unwrap(); + assert_eq!(albums.len(), 2); + assert_eq!(albums[1].id, "al_2"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_album_includes_song_list() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", "al_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": "al_1", + "name": "Test Album", + "songCount": 2, + "song": [ + { "id": "tr_1", "title": "One", "track": 1 }, + { "id": "tr_2", "title": "Two", "track": 2 } + ] + } + } + }))) + .mount(&server) + .await; + + let album = test_client(&server.uri()).get_album("al_1").await.unwrap(); + assert_eq!(album.song.len(), 2); + assert_eq!(album.song[0].title, "One"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn search3_handles_empty_query_navidrome_quirk() { + // Spec §2.4: Navidrome accepts empty query → returns all songs paged. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("query", "")) + .and(query_param("songCount", "100")) + .and(query_param("songOffset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "searchResult3": { + "song": [ + { "id": "tr_1", "title": "One" }, + { "id": "tr_2", "title": "Two" } + ] + } + } + }))) + .mount(&server) + .await; + + let sr = test_client(&server.uri()).search3("", 100, 0, None).await.unwrap(); + assert_eq!(sr.song.len(), 2); + assert!(sr.artist.is_empty()); + assert!(sr.album.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn base_url_trailing_slash_does_not_double_up() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok" } + }))) + .mount(&server) + .await; + + // Append a trailing slash + additional slashes — the constructor + // strips them so the request path stays `/rest/ping.view`, not + // `//rest/ping.view`. + let url = format!("{}///", server.uri()); + SubsonicClient::with_static_credentials(url, test_credentials(), reqwest::Client::new()) + .ping() + .await + .expect("ping with trailing slashes must reach the same endpoint"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn http_500_returns_http_status_error_without_decode() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let err = test_client(&server.uri()).ping().await.unwrap_err(); + match err { + SubsonicError::HttpStatus(s) => assert_eq!(s.as_u16(), 500), + other => panic!("expected HttpStatus, got {other:?}"), + } + } + + // ── PR-2b: fresh-credentials-per-request lifecycle ──────────────────── + + #[test] + fn from_password_client_derives_fresh_credentials_per_request() { + let client = SubsonicClient::new("http://test", "user", "pw"); + let a = client.build_credentials(); + let b = client.build_credentials(); + assert_ne!(a.salt, b.salt, "from_password mode must refresh salt"); + assert_ne!(a.token, b.token, "different salt → different token"); + assert_eq!(a.username, b.username); + } + + #[test] + fn static_credentials_client_returns_same_triple_each_call() { + let creds = SubsonicCredentials::with_static("u", "tok", "salt"); + let client = SubsonicClient::with_static_credentials( + "http://test", + creds, + reqwest::Client::new(), + ); + let a = client.build_credentials(); + let b = client.build_credentials(); + assert_eq!(a.token, b.token); + assert_eq!(a.salt, b.salt); + } + + #[tokio::test(flavor = "multi_thread")] + async fn from_password_client_sends_unique_salt_per_request_over_the_wire() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok" } + }))) + .mount(&server) + .await; + + let client = SubsonicClient::new(server.uri(), "user", "pw"); + client.ping().await.unwrap(); + client.ping().await.unwrap(); + + let received = server.received_requests().await.expect("requests captured"); + assert_eq!(received.len(), 2); + let salt = |r: &wiremock::Request| { + r.url + .query_pairs() + .find(|(k, _)| k == "s") + .map(|(_, v)| v.into_owned()) + .expect("`s` param present") + }; + let token = |r: &wiremock::Request| { + r.url + .query_pairs() + .find(|(k, _)| k == "t") + .map(|(_, v)| v.into_owned()) + .expect("`t` param present") + }; + assert_ne!(salt(&received[0]), salt(&received[1])); + assert_ne!(token(&received[0]), token(&received[1])); + } + + #[tokio::test(flavor = "multi_thread")] + async fn client_id_query_param_carries_crate_version() { + // PR-2b note 2: align `c` with the frontend (`psysonic/`). + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok" } + }))) + .mount(&server) + .await; + test_client(&server.uri()).ping().await.unwrap(); + + let received = server.received_requests().await.expect("requests captured"); + let c = received[0] + .url + .query_pairs() + .find(|(k, _)| k == "c") + .map(|(_, v)| v.into_owned()) + .expect("`c` param present"); + assert!(c.starts_with("psysonic/"), "got `{c}`"); + assert_eq!(c, SUBSONIC_CLIENT_ID); + } + + // ── PR-2b: raw_json capture for ingest (PR-3 prep) ──────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_with_raw_returns_typed_and_raw_subtree() { + let server = MockServer::start().await; + let song = json!({ + "id": "tr_1", + "title": "Title", + "artist": "Artist", + "musicBrainzId": "abc-123", + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 }, + "contributors": [ + { "role": "producer", "artistId": "ar_9", "name": "Prod" } + ] + }); + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "song": song.clone() } + }))) + .mount(&server) + .await; + + let (typed, raw) = test_client(&server.uri()) + .get_song_with_raw("tr_1") + .await + .unwrap(); + assert_eq!(typed.id, "tr_1"); + assert_eq!(typed.title, "Title"); + // Typed struct picks up the new musicBrainzId alias. + assert_eq!(typed.mbid_recording.as_deref(), Some("abc-123")); + + // Raw value preserves OpenSubsonic extensions the typed struct + // doesn't mirror — exactly what `track.raw_json` needs. + assert_eq!(raw.get("replayGain"), song.get("replayGain")); + assert_eq!(raw.get("contributors"), song.get("contributors")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn search3_with_raw_keeps_song_extensions_in_raw_tree() { + let server = MockServer::start().await; + let result_body = json!({ + "song": [ + { "id": "tr_1", "title": "One", "replayGain": { "trackGain": -1.5 } }, + { "id": "tr_2", "title": "Two", "contributors": [{ "role": "producer" }] } + ] + }); + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": result_body.clone() } + }))) + .mount(&server) + .await; + + let (typed, raw) = test_client(&server.uri()) + .search3_with_raw("", 100, 0, None) + .await + .unwrap(); + assert_eq!(typed.song.len(), 2); + + // Raw value preserves the typed-struct-incompatible fields. + let raw_songs = raw.get("song").and_then(|v| v.as_array()).expect("song array"); + assert_eq!(raw_songs.len(), 2); + assert_eq!( + raw_songs[0].get("replayGain"), + result_body.get("song").unwrap().as_array().unwrap()[0].get("replayGain") + ); + assert_eq!( + raw_songs[1].get("contributors"), + result_body.get("song").unwrap().as_array().unwrap()[1].get("contributors") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn search3_with_raw_empty_envelope_maps_to_empty_search_result() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": {} } + }))) + .mount(&server) + .await; + let (typed, raw) = test_client(&server.uri()) + .search3_with_raw("", 50, 0, None) + .await + .unwrap(); + assert!(typed.song.is_empty()); + assert!(typed.album.is_empty()); + // Empty `searchResult3: {}` survives as an empty Object in raw, + // not Null — runner relies on this for the `get("song")` path. + assert!(raw.is_object()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_album_with_raw_keeps_song_extensions_in_raw_tree() { + let server = MockServer::start().await; + let album = json!({ + "id": "al_1", + "name": "Album", + "song": [ + { "id": "tr_1", "title": "One", "track": 1, "musicBrainzId": "mb-1" }, + { "id": "tr_2", "title": "Two", "track": 2, "replayGain": { "trackGain": -3.0 } } + ] + }); + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "album": album.clone() } + }))) + .mount(&server) + .await; + + let (typed, raw) = test_client(&server.uri()) + .get_album_with_raw("al_1") + .await + .unwrap(); + assert_eq!(typed.song.len(), 2); + assert_eq!(typed.song[0].mbid_recording.as_deref(), Some("mb-1")); + + // Per-track raw entries survive in `raw.song[i]`. + let raw_songs = raw.get("song").and_then(|v| v.as_array()).expect("song array"); + assert_eq!(raw_songs.len(), 2); + assert_eq!( + raw_songs[1].get("replayGain"), + album.get("song").unwrap().as_array().unwrap()[1].get("replayGain") + ); + } + + // ── server_info / parse_server_info ──────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn server_info_extracts_navidrome_envelope_metadata() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "0.55.2", + "openSubsonic": true + } + }))) + .mount(&server) + .await; + + let info = test_client(&server.uri()).server_info().await.unwrap(); + assert_eq!(info.server_type.as_deref(), Some("navidrome")); + assert_eq!(info.server_version.as_deref(), Some("0.55.2")); + assert_eq!(info.api_version.as_deref(), Some("1.16.1")); + assert!(info.open_subsonic); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_info_falls_back_to_defaults_for_minimal_envelope() { + // Older Subsonic servers may omit type / serverVersion / openSubsonic. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.16.1" } + }))) + .mount(&server) + .await; + + let info = test_client(&server.uri()).server_info().await.unwrap(); + assert!(info.server_type.is_none()); + assert!(info.server_version.is_none()); + assert!(!info.open_subsonic); + assert_eq!(info.api_version.as_deref(), Some("1.16.1")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_info_surfaces_wrong_credentials_as_code_40() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong username or password" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()).server_info().await.unwrap_err(); + assert!(matches!(err, SubsonicError::Api { code: 40, .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_song_with_raw_maps_error_70_to_not_found_like_get_song() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }))) + .mount(&server) + .await; + + let err = test_client(&server.uri()) + .get_song_with_raw("missing") + .await + .unwrap_err(); + assert!(matches!(err, SubsonicError::NotFound)); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/error.rs b/src-tauri/crates/psysonic-integration/src/subsonic/error.rs new file mode 100644 index 00000000..bd253848 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/error.rs @@ -0,0 +1,76 @@ +use std::fmt; + +/// Errors surfaced by `SubsonicClient`. Designed for the sync engine +/// (PR-3): `NotFound` exists as a first-class variant so the tombstone +/// reconciler can match Subsonic error code 70 without parsing strings. +/// +/// Spec §2.6 — code 70 = "The requested data was not found". +#[derive(Debug)] +pub enum SubsonicError { + /// Transport failure (DNS, TCP, TLS, body read). Wraps the flattened + /// reqwest error chain so toasts can surface the real cause. + Transport(String), + + /// Server replied with a non-2xx HTTP status before the JSON envelope + /// was inspectable. + HttpStatus(reqwest::StatusCode), + + /// Subsonic-level failure (`status = "failed"` in the envelope). + Api { code: i32, message: String }, + + /// Convenience for the common error code 70. Equivalent to + /// `Api { code: 70, .. }` and produced by the same parser. + NotFound, + + /// Response body wasn't the expected JSON shape. + Decode(String), +} + +impl fmt::Display for SubsonicError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SubsonicError::Transport(m) => write!(f, "subsonic transport: {m}"), + SubsonicError::HttpStatus(s) => write!(f, "subsonic http status: {s}"), + SubsonicError::Api { code, message } => { + write!(f, "subsonic api error {code}: {message}") + } + SubsonicError::NotFound => write!(f, "subsonic: not found (code 70)"), + SubsonicError::Decode(m) => write!(f, "subsonic decode: {m}"), + } + } +} + +impl std::error::Error for SubsonicError {} + +/// Flatten a `reqwest::Error` source chain into one readable string — +/// mirrors `psysonic-integration::navidrome::nd_err` so the two clients +/// surface comparable diagnostic text. +pub(crate) fn flatten_reqwest_error(e: reqwest::Error) -> String { + let mut msg = e.to_string(); + let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e); + while let Some(s) = src { + msg.push_str(" | "); + msg.push_str(&s.to_string()); + src = s.source(); + } + msg +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_includes_error_code_for_api_variant() { + let e = SubsonicError::Api { code: 40, message: "Wrong username or password".into() }; + let s = e.to_string(); + assert!(s.contains("40")); + assert!(s.contains("Wrong username")); + } + + #[test] + fn not_found_renders_with_code_70_for_log_search() { + let s = SubsonicError::NotFound.to_string(); + assert!(s.contains("70"), "got {s}"); + } +} diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs b/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs new file mode 100644 index 00000000..36afd40c --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/mod.rs @@ -0,0 +1,18 @@ +//! Subsonic REST client — read-only endpoints the library-sync engine +//! consumes (phase B per spec §10). See `client::SubsonicClient` for the +//! entry point. + +pub mod auth; +pub mod client; +pub mod error; +pub mod types; + +pub use auth::SubsonicCredentials; +pub use client::{ + fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID, +}; +pub use error::SubsonicError; +pub use types::{ + Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo, + Song, +}; diff --git a/src-tauri/crates/psysonic-integration/src/subsonic/types.rs b/src-tauri/crates/psysonic-integration/src/subsonic/types.rs new file mode 100644 index 00000000..11d7cf46 --- /dev/null +++ b/src-tauri/crates/psysonic-integration/src/subsonic/types.rs @@ -0,0 +1,403 @@ +//! Response structs for the Subsonic REST API surface PR-2 needs. +//! +//! Only the hot fields the sync engine reads are typed; everything else +//! survives via the raw JSON the client also returns (PR-3 wires the +//! `raw_json` column on `track`). Unknown fields are simply ignored on +//! deserialize — additive OpenSubsonic extensions never break parsing. + +use serde::{Deserialize, Serialize}; + +/// Deserialize a field Navidrome/OpenSubsonic may return either as a plain +/// string or as a JSON array. OpenSubsonic types `isrc` as `string[]`; +/// Navidrome ships `isrc: []` / `["USRC…"]`, which breaks a plain +/// `Option`. Take the first usable value; the full set survives +/// verbatim in `track.raw_json` (ADR-7). +fn de_string_or_seq<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::String(s)) => Some(s), + Some(serde_json::Value::Array(arr)) => first_tag_value(&arr), + _ => None, + }) +} + +/// Navidrome often ships library ids as JSON numbers; Subsonic uses strings. +fn de_string_or_number<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::String(s)) => Some(s), + Some(serde_json::Value::Number(n)) => Some(n.to_string()), + _ => None, + }) +} + +/// First usable value in a multi-valued array: a string element, or an +/// object element's `name` (the OpenSubsonic `[{ "name": … }]` shape). +fn first_tag_value(arr: &[serde_json::Value]) -> Option { + arr.iter().find_map(|el| match el { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Object(map) => map + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + _ => None, + }) +} + +/// Envelope-level metadata returned by `#ping` (and present on every +/// other response too). Read by the capability probe to detect the +/// server family (Navidrome vs generic Subsonic) and the OpenSubsonic +/// flag. Filled in from the `subsonic-response` object itself, not +/// from a body key — these fields sit at the same level as `status`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ServerInfo { + /// Server software family — Navidrome reports `"navidrome"`, generic + /// Subsonic implementations report their own label. `None` when the + /// server omits the field (older Subsonic). + pub server_type: Option, + /// Server build version, e.g. `"0.55.2"` on Navidrome. + pub server_version: Option, + /// Subsonic API protocol level the server advertises. + pub api_version: Option, + /// `true` when the server advertises OpenSubsonic extensions + /// (`isrc`, `played`, `bpm`, contributor arrays, …). + pub open_subsonic: bool, +} + +/// `#getScanStatus` (since 1.15.0). `lastScan` is an ISO-8601 string on +/// Navidrome (`responses.go` `ScanStatus.LastScan`); other servers may +/// omit it during an active scan. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ScanStatus { + pub scanning: bool, + #[serde(default)] + pub count: Option, + #[serde(rename = "folderCount", default)] + pub folder_count: Option, + #[serde(rename = "lastScan", default)] + pub last_scan: Option, +} + +/// `#getIndexes` (file-structure browse) and `#getArtists` (ID3 browse) +/// share the same shape on the wire: a top-level `lastModified` watermark +/// plus a list of letter buckets. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ArtistIndex { + /// `lastModified` is ms since epoch (spec §2.2 — response metadata, + /// not a request param). + #[serde(rename = "lastModified", default)] + pub last_modified_ms: Option, + #[serde(rename = "ignoredArticles", default)] + pub ignored_articles: Option, + #[serde(default)] + pub index: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct IndexBucket { + pub name: String, + #[serde(default)] + pub artist: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct ArtistRef { + pub id: String, + pub name: String, + #[serde(rename = "albumCount", default)] + pub album_count: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, +} + +/// `#getAlbumList2` — page of album summaries (no song list). +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct AlbumSummary { + pub id: String, + pub name: String, + #[serde(default)] + pub artist: Option, + #[serde(rename = "artistId", default)] + pub artist_id: Option, + #[serde(rename = "songCount", default)] + pub song_count: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub genre: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(default)] + pub starred: Option, +} + +/// `#getAlbum` — album + its full song list. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Album { + pub id: String, + pub name: String, + #[serde(default)] + pub artist: Option, + #[serde(rename = "artistId", default)] + pub artist_id: Option, + #[serde(rename = "songCount", default)] + pub song_count: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub genre: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(default)] + pub song: Vec, +} + +/// `#search3` — three parallel lists, any of which may be empty. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)] +pub struct SearchResult { + #[serde(default)] + pub artist: Vec, + #[serde(default)] + pub album: Vec, + #[serde(default)] + pub song: Vec, +} + +/// `#getSong` / nested in `#getAlbum`. Only the hot columns from +/// spec §5.1 are typed; everything else (OpenSubsonic extensions, contributor +/// arrays, …) is ignored at this layer and recovered from `raw_json` later. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Song { + pub id: String, + pub title: String, + #[serde(default)] + pub artist: Option, + #[serde(rename = "artistId", default)] + pub artist_id: Option, + #[serde(default)] + pub album: Option, + #[serde(rename = "albumId", default)] + pub album_id: Option, + #[serde(rename = "albumArtist", default)] + pub album_artist: Option, + /// Subsonic reports `duration` in whole seconds. + #[serde(default)] + pub duration: Option, + #[serde(rename = "track", default)] + pub track_number: Option, + #[serde(rename = "discNumber", default)] + pub disc_number: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub genre: Option, + #[serde(default)] + pub suffix: Option, + #[serde(rename = "bitRate", default)] + pub bit_rate: Option, + /// Server reports `size` in bytes. + #[serde(default)] + pub size: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(default)] + pub starred: Option, + #[serde(rename = "userRating", default)] + pub user_rating: Option, + #[serde(rename = "playCount", default)] + pub play_count: Option, + #[serde(default)] + pub played: Option, + /// Server-side relative path (Navidrome populates; some servers don't). + #[serde(default)] + pub path: Option, + /// `libraryId` (Navidrome native) or `musicFolderId` (Subsonic generic). + /// We accept both keys — Navidrome uses `libraryId` on OpenSubsonic + /// responses, generic Subsonic stays on `musicFolderId`. + #[serde( + default, + alias = "libraryId", + alias = "musicFolderId", + deserialize_with = "de_string_or_number" + )] + pub library_id: Option, + // OpenSubsonic types `isrc` as `string[]` — Navidrome returns + // `isrc: []` / `["USRC…"]`, which breaks a plain `Option`. + #[serde(default, deserialize_with = "de_string_or_seq")] + pub isrc: Option, + /// MusicBrainz recording id. Subsonic / OpenSubsonic uses the + /// `musicBrainzId` JSON key; the schema column is `mbid_recording` + /// (spec §5.1). The alias keeps both spellings deserializable so + /// future API revisions don't break ingest. + #[serde(default, alias = "musicBrainzId", alias = "mbid_recording")] + pub mbid_recording: Option, + #[serde(default)] + pub bpm: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn song_deserialize_accepts_minimal_navidrome_payload() { + let payload = r#"{ + "id": "tr_1", + "title": "Hello", + "artist": "World", + "duration": 240, + "track": 3, + "year": 2024, + "suffix": "flac", + "bitRate": 1000, + "size": 32000000, + "coverArt": "cv_1", + "libraryId": "1", + "isrc": "USRC17607839" + }"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.id, "tr_1"); + assert_eq!(song.title, "Hello"); + assert_eq!(song.duration, Some(240)); + assert_eq!(song.track_number, Some(3)); + assert_eq!(song.library_id.as_deref(), Some("1")); + assert_eq!(song.isrc.as_deref(), Some("USRC17607839")); + } + + #[test] + fn song_alias_falls_back_to_music_folder_id() { + // Generic Subsonic still ships `musicFolderId`, not Navidrome's + // `libraryId` — make sure we don't lose it. + let payload = r#"{"id":"a","title":"t","musicFolderId":"7"}"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.library_id.as_deref(), Some("7")); + } + + #[test] + fn song_deserialize_library_id_from_number() { + let payload = r#"{"id":"a","title":"t","libraryId":3}"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.library_id.as_deref(), Some("3")); + } + + #[test] + fn song_picks_up_music_brainz_id_from_either_alias() { + // OpenSubsonic shape — `musicBrainzId`. + let from_subsonic: Song = serde_json::from_str( + r#"{"id":"a","title":"t","musicBrainzId":"abc-123"}"#, + ) + .unwrap(); + assert_eq!(from_subsonic.mbid_recording.as_deref(), Some("abc-123")); + + // Schema-column shape — direct `mbid_recording`. Lets callers + // round-trip a row through `serde_json` without renaming. + let from_schema: Song = serde_json::from_str( + r#"{"id":"a","title":"t","mbid_recording":"xyz-789"}"#, + ) + .unwrap(); + assert_eq!(from_schema.mbid_recording.as_deref(), Some("xyz-789")); + } + + #[test] + fn song_ignores_unknown_open_subsonic_fields() { + // OpenSubsonic ships extras like `played`, `replayGain`, `artists` + // (contributor list), etc. We don't type them; they must not error. + let payload = r#"{ + "id": "tr_1", + "title": "Hello", + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 }, + "artists": [{ "id": "ar_1", "name": "W" }], + "contributors": [] + }"#; + let song: Song = serde_json::from_str(payload).unwrap(); + assert_eq!(song.id, "tr_1"); + assert!(song.artist.is_none()); + } + + #[test] + fn album_with_songs_round_trips() { + let payload = r#"{ + "id": "al_1", + "name": "Test Album", + "artist": "Artist", + "artistId": "ar_1", + "songCount": 2, + "song": [ + {"id": "tr_1", "title": "One", "track": 1}, + {"id": "tr_2", "title": "Two", "track": 2} + ] + }"#; + let album: Album = serde_json::from_str(payload).unwrap(); + assert_eq!(album.name, "Test Album"); + assert_eq!(album.song.len(), 2); + assert_eq!(album.song[1].title, "Two"); + } + + #[test] + fn song_isrc_accepts_opensubsonic_string_array() { + // OpenSubsonic `isrc` is `string[]`. Navidrome ships `isrc: []` + // (the album !Brincamos! repro) or a populated array — both must + // decode, plus the legacy single-string form. + let empty: Song = serde_json::from_str(r#"{"id":"a","title":"t","isrc":[]}"#).unwrap(); + assert!(empty.isrc.is_none()); + let arr: Song = + serde_json::from_str(r#"{"id":"a","title":"t","isrc":["USRC17607839"]}"#).unwrap(); + assert_eq!(arr.isrc.as_deref(), Some("USRC17607839")); + let legacy: Song = + serde_json::from_str(r#"{"id":"a","title":"t","isrc":"USRC17607839"}"#).unwrap(); + assert_eq!(legacy.isrc.as_deref(), Some("USRC17607839")); + } + + #[test] + fn artist_index_parses_last_modified_watermark() { + let payload = r#"{ + "lastModified": 1716840000000, + "ignoredArticles": "The El La", + "index": [ + {"name": "A", "artist": [ + {"id": "ar_1", "name": "Anna"}, + {"id": "ar_2", "name": "Alex", "albumCount": 3} + ]}, + {"name": "B", "artist": []} + ] + }"#; + let ai: ArtistIndex = serde_json::from_str(payload).unwrap(); + assert_eq!(ai.last_modified_ms, Some(1716840000000)); + assert_eq!(ai.index.len(), 2); + assert_eq!(ai.index[0].artist.len(), 2); + assert_eq!(ai.index[0].artist[1].album_count, Some(3)); + } + + #[test] + fn search_result_defaults_empty_lists() { + // search3 with no hits returns just `{"searchResult3": {}}`. + let sr: SearchResult = serde_json::from_str("{}").unwrap(); + assert!(sr.artist.is_empty()); + assert!(sr.album.is_empty()); + assert!(sr.song.is_empty()); + } + + #[test] + fn scan_status_parses_navidrome_shape() { + let payload = r#"{ + "scanning": false, + "count": 12345, + "folderCount": 100, + "lastScan": "2024-06-01T12:00:00Z" + }"#; + let s: ScanStatus = serde_json::from_str(payload).unwrap(); + assert!(!s.scanning); + assert_eq!(s.count, Some(12345)); + assert_eq!(s.last_scan.as_deref(), Some("2024-06-01T12:00:00Z")); + } +} diff --git a/src-tauri/crates/psysonic-library/Cargo.toml b/src-tauri/crates/psysonic-library/Cargo.toml new file mode 100644 index 00000000..ad2ccaf5 --- /dev/null +++ b/src-tauri/crates/psysonic-library/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "psysonic-library" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +psysonic-core = { path = "../psysonic-core" } +psysonic-integration = { path = "../psysonic-integration" } + +tauri = { version = "2" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.39", features = ["bundled"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] } +wiremock = { workspace = true } diff --git a/src-tauri/crates/psysonic-library/migrations/001_initial.sql b/src-tauri/crates/psysonic-library/migrations/001_initial.sql new file mode 100644 index 00000000..f1a0c8f8 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/001_initial.sql @@ -0,0 +1,247 @@ +-- psysonic-library v1 schema — see implementation-spec.ru.md §5.1–§5.3 +-- Tables are ordered so FK targets exist before their referrers. + +-- Migration-runner bookkeeping (§5.7). Defined here so the schema file is +-- self-describing — `LibraryStore::run_migrations` also creates this table +-- defensively before applying migrations, which keeps both paths idempotent. +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE TABLE canonical_track ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE canonical_identity ( + canonical_id TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 1.0, + PRIMARY KEY (kind, value), + FOREIGN KEY (canonical_id) REFERENCES canonical_track(id) +); + +CREATE TABLE sync_state ( + server_id TEXT NOT NULL, + library_scope TEXT NOT NULL DEFAULT '', + normalized_base_url TEXT NOT NULL DEFAULT '', + server_fingerprint_ok INTEGER, + fingerprint_checked_at INTEGER, + capability_flags INTEGER NOT NULL DEFAULT 0, + last_full_sync_at INTEGER, + last_delta_sync_at INTEGER, + server_last_scan_iso TEXT, + indexes_last_modified_ms INTEGER, + artists_last_modified_ms INTEGER, + server_track_count INTEGER, + local_track_count INTEGER, + artist_count INTEGER, + library_tier TEXT NOT NULL DEFAULT 'unknown', + poll_stats_json TEXT NOT NULL DEFAULT '{}', + next_poll_at INTEGER, + initial_sync_cursor_json TEXT NOT NULL DEFAULT '{}', + sync_phase TEXT NOT NULL DEFAULT 'idle', + last_error TEXT, + PRIMARY KEY (server_id, library_scope) +); + +CREATE TABLE artist ( + server_id TEXT NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + album_count INTEGER, + synced_at INTEGER NOT NULL, + raw_json TEXT, + PRIMARY KEY (server_id, id) +); + +CREATE TABLE album ( + server_id TEXT NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + artist TEXT, + artist_id TEXT, + song_count INTEGER, + duration_sec INTEGER, + year INTEGER, + genre TEXT, + cover_art_id TEXT, + starred_at INTEGER, + synced_at INTEGER NOT NULL, + raw_json TEXT, + PRIMARY KEY (server_id, id) +); + +CREATE TABLE track ( + server_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL, + title_sort TEXT, + artist TEXT, + artist_id TEXT, + album TEXT NOT NULL DEFAULT '', + album_id TEXT, + album_artist TEXT, + duration_sec INTEGER NOT NULL DEFAULT 0, + track_number INTEGER, + disc_number INTEGER, + year INTEGER, + genre TEXT, + suffix TEXT, + bit_rate INTEGER, + size_bytes INTEGER, + cover_art_id TEXT, + starred_at INTEGER, + user_rating INTEGER, + play_count INTEGER, + played_at INTEGER, + server_path TEXT, + library_id TEXT, + isrc TEXT, + mbid_recording TEXT, + bpm INTEGER, + replay_gain_track_db REAL, + replay_gain_album_db REAL, + content_hash TEXT, + server_updated_at INTEGER, + server_created_at INTEGER, + deleted INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, + PRIMARY KEY (server_id, id) +); + +CREATE VIRTUAL TABLE track_fts USING fts5( + title, artist, album, album_artist, genre, + content='track', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' +); + +CREATE TRIGGER track_ai AFTER INSERT ON track BEGIN + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; + +CREATE TRIGGER track_ad AFTER DELETE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); +END; + +CREATE TRIGGER track_au AFTER UPDATE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; + +CREATE TABLE track_extension ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + kind TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + payload BLOB NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id, kind), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) +); + +-- NO FK to track: survives library purge when user keeps the cached file (§5.14). +CREATE TABLE track_offline ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + local_path TEXT NOT NULL, + file_size_bytes INTEGER, + suffix TEXT, + content_hash TEXT NOT NULL DEFAULT '', + server_path TEXT, + cached_at INTEGER NOT NULL, + last_verified_at INTEGER, + PRIMARY KEY (server_id, track_id) +); + +CREATE TABLE track_id_history ( + server_id TEXT NOT NULL, + old_id TEXT NOT NULL, + new_id TEXT NOT NULL, + content_hash TEXT, + server_path TEXT, + remapped_at INTEGER NOT NULL, + PRIMARY KEY (server_id, old_id) +); + +CREATE TABLE track_fact ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + fact_kind TEXT NOT NULL, + value_real REAL, + value_int INTEGER, + value_text TEXT, + unit TEXT, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + source_detail TEXT, + confidence REAL NOT NULL DEFAULT 1.0, + content_hash TEXT, + fetched_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (server_id, track_id, fact_kind, source_kind, source_id), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) +); + +CREATE TABLE track_artifact ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + artifact_kind TEXT NOT NULL, + format TEXT NOT NULL, + language TEXT, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + content_text TEXT, + content_blob BLOB, + content_bytes INTEGER NOT NULL DEFAULT 0, + not_found INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + fetched_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (server_id, track_id, artifact_kind, source_kind, source_id, format), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) +); + +CREATE TABLE track_canonical_link ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + canonical_id TEXT NOT NULL, + match_method TEXT NOT NULL, + confidence REAL NOT NULL, + linked_at INTEGER NOT NULL, + PRIMARY KEY (server_id, track_id), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id), + FOREIGN KEY (canonical_id) REFERENCES canonical_track(id) +); + +CREATE TABLE canonical_enrichment_link ( + canonical_id TEXT NOT NULL, + enrichment_kind TEXT NOT NULL, + owner_server_id TEXT NOT NULL, + owner_track_id TEXT NOT NULL, + share_policy TEXT NOT NULL DEFAULT 'isrc_match', + linked_at INTEGER NOT NULL, + PRIMARY KEY (canonical_id, enrichment_kind, owner_server_id, owner_track_id), + FOREIGN KEY (canonical_id) REFERENCES canonical_track(id) +); + +CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0; +CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0; +CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0; +CREATE INDEX idx_track_starred ON track(server_id, starred_at) WHERE deleted = 0 AND starred_at IS NOT NULL; +CREATE INDEX idx_track_library ON track(server_id, library_id) WHERE deleted = 0; +CREATE INDEX idx_track_bpm ON track(server_id, bpm) WHERE deleted = 0 AND bpm IS NOT NULL; +CREATE INDEX idx_track_isrc ON track(isrc) WHERE deleted = 0 AND isrc IS NOT NULL; +CREATE INDEX idx_track_fact_lookup ON track_fact(server_id, track_id, fact_kind); +CREATE INDEX idx_track_artifact_lookup ON track_artifact(server_id, track_id, artifact_kind); +CREATE INDEX idx_track_offline_hash ON track_offline(server_id, content_hash); +CREATE INDEX idx_track_id_history_new ON track_id_history(server_id, new_id); +CREATE INDEX idx_canonical_identity_lookup ON canonical_identity(kind, value); diff --git a/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql b/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql new file mode 100644 index 00000000..995f4f45 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql @@ -0,0 +1,6 @@ +-- psysonic-library schema v2 — large-library ingest policy (R7-15). +-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a +-- deep offset on a large catalog, the strategy selector stops choosing N1 for +-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive +-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall. +ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0; diff --git a/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql b/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql new file mode 100644 index 00000000..bd2f2e43 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql @@ -0,0 +1,10 @@ +-- Remap detection (§6.9) and unstable-id servers: without these indexes +-- each upsert in a 500-row batch can scan the whole track table. + +CREATE INDEX IF NOT EXISTS idx_track_remap_path + ON track(server_id, server_path) + WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != ''; + +CREATE INDEX IF NOT EXISTS idx_track_remap_hash + ON track(server_id, content_hash) + WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql b/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql new file mode 100644 index 00000000..d94f9575 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql @@ -0,0 +1,4 @@ +-- Browse / sort-by-title without sorting the full server slice on every page. +CREATE INDEX IF NOT EXISTS idx_track_title + ON track(server_id, title COLLATE NOCASE) + WHERE deleted = 0; diff --git a/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql b/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql new file mode 100644 index 00000000..b0fc60c3 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql @@ -0,0 +1,8 @@ +-- Advanced search filters on genre and year (partial indexes — only non-null rows). +CREATE INDEX IF NOT EXISTS idx_track_genre + ON track(server_id, genre COLLATE NOCASE) + WHERE deleted = 0 AND genre IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_track_year + ON track(server_id, year) + WHERE deleted = 0 AND year IS NOT NULL; diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs new file mode 100644 index 00000000..a707496c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -0,0 +1,1471 @@ +//! Advanced Search SQL builder (spec §5.13). PR-5d ships the backend only — +//! the `AdvancedSearch.tsx` UI wiring stays PR-7 (F2). Cross-server search +//! (§5.5B) lives in the sibling `cross_server` module. +//! +//! The builder turns a `LibraryAdvancedSearchRequest` into one parameterised +//! query per requested entity (track / album / artist), each sharing a WHERE +//! built from the `FilterFieldRegistry` resolution in `filter.rs`. Only +//! builder-supplied column expressions ever reach the SQL string; every value +//! is bound (§5.13.5: parameterised only). + +use std::collections::{BTreeSet, HashSet}; + +use rusqlite::types::Value as SqlValue; +use serde_json::Value; + +use crate::dto::{ + LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto, + LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir, +}; +use crate::filter::{self, EntityKind, FilterOp, SqlFragment}; +use crate::repos; +use crate::search::{ + aliased_track_columns, fts_album_prefix_match_query, fts_column_prefix_query, + fts_query_meets_min_len, fts_track_prefix_match_query, library_scope_equals_sql, + like_contains, PAGE_LIMIT_MAX, +}; +use crate::store::LibraryStore; + +/// `bpm` dual-storage resolution (§5.13.4): prefer the hot `track.bpm` +/// column, fall back to the highest-priority `track_fact(bpm)` value. The +/// spec's `not_found = 0` guard is dropped — the live `track_fact` schema +/// has no such column (that lives on `track_artifact`). +const BPM_RESOLVED_EXPR: &str = "COALESCE(t.bpm, (SELECT f.value_int FROM track_fact f \ + WHERE f.server_id = t.server_id AND f.track_id = t.id AND f.fact_kind = 'bpm' \ + ORDER BY CASE f.source_kind WHEN 'user' THEN 0 WHEN 'server_tag' THEN 1 \ + WHEN 'analysis' THEN 2 ELSE 3 END LIMIT 1))"; + +const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \ + a.song_count, a.duration_sec, a.year, a.genre, a.cover_art_id, a.starred_at, \ + a.synced_at, a.raw_json"; + +const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \ + ar.synced_at, ar.raw_json"; + +/// Flat track projection used when browsing albums in advanced search. +type AlbumBrowseTrackRow = ( + String, + String, + String, + Option, + Option, + Option, + Option, + Option, + Option, + i64, +); + +fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 { + let need = limit.saturating_add(offset) as i64; + need.saturating_mul(20).clamp(256, 10_000) +} + +/// `library_advanced_search` (§5.13). Runs only the queries named in +/// `entityTypes`; absent entities return empty + zero totals. +pub fn run_advanced_search( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, +) -> Result { + // `query` shorthand → text input; a `text` filter clause is an alias for + // the same thing. Everything else is a scalar filter. + let mut text_input: Option = trimmed_nonempty(req.query.as_deref()); + let mut scalar: Vec<&LibraryFilterClause> = Vec::new(); + for c in &req.filters { + if c.field == "text" { + if text_input.is_none() { + if let Some(Value::String(s)) = &c.value { + text_input = trimmed_nonempty(Some(s)); + } + } + } else { + scalar.push(c); + } + } + + // Up-front validation: an unknown field or an op the registry doesn't + // declare is an error regardless of entity routing (§5.13.5). + for c in &scalar { + let field = filter::lookup(&c.field) + .ok_or_else(|| filter::FilterError::UnknownField(c.field.clone()).to_string())?; + if !field.ops.contains(&c.op) { + return Err(filter::FilterError::UnsupportedOp { + field: c.field.clone(), + op: c.op.as_str(), + } + .to_string()); + } + } + + if text_input + .as_deref() + .is_some_and(|t| !fts_query_meets_min_len(t)) + { + return Ok(LibraryAdvancedSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + totals: LibrarySearchTotals { + artists: 0, + albums: 0, + tracks: 0, + }, + applied_filters: Vec::new(), + source: "local".to_string(), + }); + } + + let limit = req.limit.clamp(1, PAGE_LIMIT_MAX); + let offset = req.offset; + let skip_totals = req.skip_totals; + let text = text_input.as_deref(); + let want = |k: EntityKind| req.entity_types.contains(&k); + let mut applied: BTreeSet = BTreeSet::new(); + + let (artists, artists_total) = if want(EntityKind::Artist) { + build_artist(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)? + } else { + (Vec::new(), 0) + }; + let (albums, albums_total) = if want(EntityKind::Album) { + build_album(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)? + } else { + (Vec::new(), 0) + }; + let (tracks, tracks_total) = if want(EntityKind::Track) { + build_track(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)? + } else { + (Vec::new(), 0) + }; + + Ok(LibraryAdvancedSearchResponse { + artists, + albums, + tracks, + totals: LibrarySearchTotals { + artists: artists_total, + albums: albums_total, + tracks: tracks_total, + }, + applied_filters: applied.into_iter().collect(), + source: "local".to_string(), + }) +} + +// ── per-entity builders ──────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn build_track( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("t.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let cols = aliased_track_columns("t"); + if let Some(q) = text.and_then(fts_track_prefix_match_query) { + applied.insert("text".to_string()); + let pool = fts_candidate_pool_size(limit, offset); + let from = format!( + "track t INNER JOIN (\ + SELECT rowid, bm25(track_fts) AS fts_rank FROM track_fts \ + WHERE track_fts MATCH ? ORDER BY fts_rank LIMIT {pool}\ + ) fts_pick ON t.rowid = fts_pick.rowid" + ); + let order = order_clause(&req.sort, EntityKind::Track) + .unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string()); + return query_rows_fts( + store, + &cols, + &from, + &q, + &w, + &order, + limit, + offset, + skip_totals, + |r| repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)), + ); + } + + let order = order_clause(&req.sort, EntityKind::Track) + .unwrap_or_else(|| "ORDER BY t.title COLLATE NOCASE ASC, t.id ASC".to_string()); + query_rows( + store, + &cols, + "track t", + &w, + &order, + limit, + offset, + skip_totals, + |r| repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)), + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_album( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; + if !table.0.is_empty() || table.1 > 0 { + return Ok(table); + } + if let Some(q) = text.and_then(fts_album_prefix_match_query) { + return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); + } + build_album_from_tracks(store, req, text, scalar, limit, offset, skip_totals, applied) +} + +#[allow(clippy::too_many_arguments)] +fn build_album_from_table( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + // `album` has no `library_id` / `deleted` columns, so `libraryScope` is + // a track-only filter (P20) and does not narrow album-table results. + let mut w = WhereBuilder::new(); + w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone())); + if let Some(t) = text { + w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Album)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("a.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let order = order_clause(&req.sort, EntityKind::Album) + .unwrap_or_else(|| "ORDER BY a.name COLLATE NOCASE ASC, a.id ASC".to_string()); + query_rows( + store, + ALBUM_COLUMNS, + "album a", + &w, + &order, + limit, + offset, + skip_totals, + map_album, + ) +} + +/// Album rows derived from synced tracks when the dedicated `album` table +/// has no matching rows (N1 / S1 ingest only writes tracks today). +#[allow(clippy::too_many_arguments)] +fn build_album_from_tracks( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); + w.push_raw( + "NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id AND a.id = t.album_id)", + ); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + if let Some(t) = text { + w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("t.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \ + COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \ + MAX(t.starred_at), MAX(t.synced_at)"; + let order = order_clause(&req.sort, EntityKind::Album).unwrap_or_else(|| { + "ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string() + }); + query_grouped_rows( + store, + select, + "track t", + &w, + "GROUP BY t.album_id", + &order, + limit, + offset, + skip_totals, + map_album_from_tracks, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_artist( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; + if !table.0.is_empty() || table.1 > 0 { + return Ok(table); + } + if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) { + return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); + } + build_artist_from_tracks(store, req, text, scalar, limit, offset, skip_totals, applied) +} + +#[allow(clippy::too_many_arguments)] +fn build_artist_from_table( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone())); + if let Some(t) = text { + w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + // Only `text` routes to artist with a real column; other registered + // fields resolve to `None` (skip). `starredOnly` has no artist column. + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Artist)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + + let order = order_clause(&req.sort, EntityKind::Artist) + .unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string()); + query_rows( + store, + ARTIST_COLUMNS, + "artist ar", + &w, + &order, + limit, + offset, + skip_totals, + map_artist, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_artist_from_tracks( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: Option<&str>, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + let mut w = WhereBuilder::new(); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''"); + w.push_raw( + "NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_id)", + ); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + if let Some(t) = text { + w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + applied.insert("text".to_string()); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + + let select = "t.server_id, t.artist_id, MAX(t.artist), COUNT(DISTINCT t.album_id), MAX(t.synced_at)"; + let order = order_clause(&req.sort, EntityKind::Artist).unwrap_or_else(|| { + "ORDER BY MAX(t.artist) COLLATE NOCASE ASC, t.artist_id ASC".to_string() + }); + query_grouped_rows( + store, + select, + "track t", + &w, + "GROUP BY t.artist_id", + &order, + limit, + offset, + skip_totals, + map_artist_from_tracks, + ) +} + +/// Text search for albums when the `album` table is empty — one FTS pass + +/// in-memory dedupe by `album_id` (same strategy as live search / §5.9). +#[allow(clippy::too_many_arguments)] +fn build_album_from_fts( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + fts: &str, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + applied.insert("text".to_string()); + let need = limit.saturating_add(offset) as i64; + let pool = (need.saturating_mul(8)).clamp(64, 2_000); + + let mut w = WhereBuilder::new(); + w.push_param( + &format!( + "t.rowid IN (SELECT rowid FROM track_fts WHERE track_fts MATCH ? ORDER BY bm25(track_fts) LIMIT {pool})" + ), + SqlValue::Text(fts.to_string()), + ); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + if req.starred_only == Some(true) { + w.push_raw("t.starred_at IS NOT NULL"); + applied.insert("starred".to_string()); + } + + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let sql = format!( + "SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ + t.genre, t.cover_art_id, t.starred_at, t.synced_at \ + FROM track t \ + WHERE {where_sql}" + ); + let params = w.params.clone(); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec = + stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + r.get(9)?, + )) + })? + .collect::>>()?; + + let mut seen = HashSet::new(); + let mut deduped: Vec = Vec::new(); + for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows { + if !seen.insert(album_id.clone()) { + continue; + } + deduped.push(LibraryAlbumDto { + server_id, + id: album_id, + name: album, + artist, + artist_id, + song_count: None, + duration_sec: None, + year, + genre, + cover_art_id, + starred_at, + synced_at, + raw_json: Value::Null, + }); + if deduped.len() >= need as usize { + break; + } + } + + let total = if skip_totals { + 0 + } else { + deduped.len() as u32 + }; + let page = deduped + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + }) +} + +/// Text search for artists when the `artist` table is empty — FTS + dedupe. +#[allow(clippy::too_many_arguments)] +fn build_artist_from_fts( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + fts: &str, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + applied.insert("text".to_string()); + let need = limit.saturating_add(offset) as i64; + let pool = (need.saturating_mul(8)).clamp(64, 2_000); + + let mut w = WhereBuilder::new(); + w.push_param( + &format!( + "t.rowid IN (SELECT rowid FROM track_fts WHERE track_fts MATCH ? ORDER BY bm25(track_fts) LIMIT {pool})" + ), + SqlValue::Text(fts.to_string()), + ); + w.push_raw("t.deleted = 0"); + w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''"); + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + let clause = library_scope_equals_sql("t"); + w.push_param(&clause, SqlValue::Text(scope)); + } + for c in scalar { + if let Some(frag) = resolve_clause(c, EntityKind::Track)? { + applied.insert(c.field.clone()); + w.push(frag); + } + } + + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let sql = format!( + "SELECT t.server_id, t.artist_id, t.artist, t.synced_at \ + FROM track t \ + WHERE {where_sql}" + ); + let params = w.params.clone(); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec<(String, String, Option, i64)> = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + })? + .collect::>>()?; + + let mut seen = HashSet::new(); + let mut deduped: Vec = Vec::new(); + for (server_id, artist_id, artist, synced_at) in rows { + if !seen.insert(artist_id.clone()) { + continue; + } + deduped.push(LibraryArtistDto { + server_id, + id: artist_id, + name: artist.unwrap_or_default(), + album_count: None, + synced_at, + raw_json: Value::Null, + }); + if deduped.len() >= need as usize { + break; + } + } + + let total = if skip_totals { + 0 + } else { + deduped.len() as u32 + }; + let page = deduped + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + }) +} + +// ── clause resolution ────────────────────────────────────────────────── + +/// Resolve one scalar clause to a WHERE fragment for `entity`. `Ok(None)` +/// means the field is known but doesn't route to this entity (§5.13.3 skip). +fn resolve_clause( + c: &LibraryFilterClause, + entity: EntityKind, +) -> Result, String> { + let applies = filter::validate_for_entity(&c.field, c.op, entity).map_err(|e| e.to_string())?; + if !applies { + return Ok(None); + } + let col = match (c.field.as_str(), entity) { + ("genre", EntityKind::Track) => "t.genre", + ("genre", EntityKind::Album) => "a.genre", + ("year", EntityKind::Track) => "t.year", + ("year", EntityKind::Album) => "a.year", + ("starred", EntityKind::Track) => "t.starred_at", + ("starred", EntityKind::Album) => "a.starred_at", + // `starred` routes to artist in the registry, but the `artist` + // table has no `starred_at` column — skip rather than error. + ("starred", EntityKind::Artist) => return Ok(None), + ("bpm", EntityKind::Track) => BPM_RESOLVED_EXPR, + // `text` is handled by the entity builder (FTS / LIKE), never here. + ("text", _) => return Ok(None), + // Registered but no v1 SQL builder (user_rating / suffix / bit_rate). + _ => return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string()), + }; + + if c.field == "genre" { + let v = json_to_text(&c.field, c.value.as_ref())?; + return Ok(Some(SqlFragment { + sql: format!("{col} = ? COLLATE NOCASE"), + params: vec![v], + })); + } + if c.field == "starred" { + return filter::compare_fragment(&c.field, col, FilterOp::IsTrue, None, None) + .map(Some) + .map_err(|e| e.to_string()); + } + // Numeric fields: year / bpm. + let value = json_to_opt_i64(&c.field, c.value.as_ref())?; + let value_to = json_to_opt_i64(&c.field, c.value_to.as_ref())?; + filter::compare_fragment(&c.field, col, c.op, value, value_to) + .map(Some) + .map_err(|e| e.to_string()) +} + +// ── query execution ──────────────────────────────────────────────────── + +/// Cap full-table FTS counts — exact totals on 100k+ hits are not worth +/// blocking the UI for tens of seconds (§5.9 p95 budget). +const FTS_MATCH_COUNT_CAP: i64 = 10_001; + +fn count_matching_rows( + conn: &rusqlite::Connection, + from: &str, + where_sql: &str, + params: &[SqlValue], + skip_totals: bool, +) -> Result { + if skip_totals { + return Ok(0); + } + if from.contains("track_fts") { + let mut bound: Vec = params.to_vec(); + bound.push(SqlValue::Integer(FTS_MATCH_COUNT_CAP)); + let count_sql = format!( + "SELECT COUNT(*) FROM (SELECT 1 FROM {from} WHERE {where_sql} LIMIT ?)" + ); + let n: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(bound.iter()), + |r| r.get(0), + )?; + return Ok(n.max(0) as u32); + } + let count_sql = format!("SELECT COUNT(*) FROM {from} WHERE {where_sql}"); + let n: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(params.iter()), + |r| r.get(0), + )?; + Ok(n.max(0) as u32) +} + +/// Accumulates `AND`-joined WHERE clauses and their positional params in +/// lockstep so anonymous `?` placeholders bind left-to-right. +struct WhereBuilder { + clauses: Vec, + params: Vec, +} + +impl WhereBuilder { + fn new() -> Self { + Self { + clauses: Vec::new(), + params: Vec::new(), + } + } + fn push(&mut self, frag: SqlFragment) { + self.clauses.push(frag.sql); + self.params.extend(frag.params); + } + fn push_raw(&mut self, sql: &str) { + self.clauses.push(sql.to_string()); + } + fn push_param(&mut self, sql: &str, param: SqlValue) { + self.clauses.push(sql.to_string()); + self.params.push(param); + } + fn where_sql(&self) -> String { + self.clauses.join(" AND ") + } +} + +/// Run the COUNT (full match total) + the paged SELECT in one connection +/// borrow. Both share `where`'s params; the page appends `LIMIT ? OFFSET ?`. +#[allow(clippy::too_many_arguments)] +fn query_rows( + store: &LibraryStore, + select_cols: &str, + from: &str, + w: &WhereBuilder, + order_sql: &str, + limit: u32, + offset: u32, + skip_totals: bool, + map: F, +) -> Result<(Vec, u32), String> +where + F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let total = count_matching_rows(conn, from, &where_sql, &w.params, skip_totals)?; + + let page_sql = format!( + "SELECT {select_cols} FROM {from} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?" + ); + let mut page_params: Vec = w.params.clone(); + page_params.push(SqlValue::Integer(limit as i64)); + page_params.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&page_sql)?; + let collected: rusqlite::Result> = stmt + .query_map(rusqlite::params_from_iter(page_params.iter()), |r| map(r))? + .collect(); + let rows = collected?; + Ok((rows, total)) + }) +} + +/// Track search with FTS rowid prefilter — MATCH param is bound first (subquery in `from`). +#[allow(clippy::too_many_arguments)] +fn query_rows_fts( + store: &LibraryStore, + select_cols: &str, + from: &str, + fts_match: &str, + w: &WhereBuilder, + order_sql: &str, + limit: u32, + offset: u32, + skip_totals: bool, + map: F, +) -> Result<(Vec, u32), String> +where + F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let mut bind: Vec = vec![SqlValue::Text(fts_match.to_string())]; + bind.extend(w.params.iter().cloned()); + + let total = count_matching_rows(conn, from, &where_sql, &bind, skip_totals)?; + + let page_sql = format!( + "SELECT {select_cols} FROM {from} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?" + ); + bind.push(SqlValue::Integer(limit as i64)); + bind.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&page_sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(bind.iter()), |r| map(r))? + .collect::>>()?; + Ok((rows, total)) + }) +} + +/// Grouped SELECT (album/artist rows derived from `track`). Skips COUNT when +/// `skip_totals` — Live Search only needs the first page. +#[allow(clippy::too_many_arguments)] +fn query_grouped_rows( + store: &LibraryStore, + select_cols: &str, + from: &str, + w: &WhereBuilder, + group_sql: &str, + order_sql: &str, + limit: u32, + offset: u32, + skip_totals: bool, + map: F, +) -> Result<(Vec, u32), String> +where + F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let where_sql = w.where_sql(); + store.with_read_conn(|conn| { + let total = if skip_totals { + 0u32 + } else { + count_matching_rows(conn, from, &where_sql, &w.params, false)? + }; + + let page_sql = format!( + "SELECT {select_cols} FROM {from} WHERE {where_sql} {group_sql} {order_sql} LIMIT ? OFFSET ?" + ); + let mut page_params: Vec = w.params.clone(); + page_params.push(SqlValue::Integer(limit as i64)); + page_params.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&page_sql)?; + let collected: rusqlite::Result> = stmt + .query_map(rusqlite::params_from_iter(page_params.iter()), |r| map(r))? + .collect(); + let rows = collected?; + Ok((rows, total)) + }) +} + +// ── row mappers ──────────────────────────────────────────────────────── + +fn map_album(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = r.get(12)?; + Ok(LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: r.get(3)?, + artist_id: r.get(4)?, + song_count: r.get(5)?, + duration_sec: r.get(6)?, + year: r.get(7)?, + genre: r.get(8)?, + cover_art_id: r.get(9)?, + starred_at: r.get(10)?, + synced_at: r.get(11)?, + raw_json: parse_raw_json(raw), + }) +} + +fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = r.get(5)?; + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + album_count: r.get(3)?, + synced_at: r.get(4)?, + raw_json: parse_raw_json(raw), + }) +} + +fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: r.get(3)?, + artist_id: r.get(4)?, + song_count: Some(r.get(5)?), + duration_sec: Some(r.get(6)?), + year: r.get(7)?, + genre: r.get(8)?, + cover_art_id: r.get(9)?, + starred_at: r.get(10)?, + synced_at: r.get(11)?, + raw_json: Value::Null, + }) +} + +fn map_artist_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + album_count: Some(r.get(3)?), + synced_at: r.get(4)?, + raw_json: Value::Null, + }) +} + +fn parse_raw_json(raw: Option) -> Value { + raw.and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null) +} + +// ── small helpers ────────────────────────────────────────────────────── + +fn trimmed_nonempty(s: Option<&str>) -> Option { + s.map(str::trim).filter(|s| !s.is_empty()).map(String::from) +} + +fn order_clause(sort: &[LibrarySortClause], entity: EntityKind) -> Option { + let mut keys: Vec = Vec::new(); + for s in sort { + if let Some(col) = sort_column(&s.field, entity) { + let dir = match s.dir { + SortDir::Asc => "ASC", + SortDir::Desc => "DESC", + }; + keys.push(format!("{col} {dir}")); + } + } + if keys.is_empty() { + None + } else { + Some(format!("ORDER BY {}", keys.join(", "))) + } +} + +/// Allowlist of sortable fields per entity → trusted column expression. +/// Unknown sort fields are ignored (fall back to the default order). +fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> { + match (field, entity) { + ("title", EntityKind::Track) => Some("t.title COLLATE NOCASE"), + ("year", EntityKind::Track) => Some("t.year"), + ("duration", EntityKind::Track) => Some("t.duration_sec"), + ("artist", EntityKind::Track) => Some("t.artist COLLATE NOCASE"), + ("album", EntityKind::Track) => Some("t.album COLLATE NOCASE"), + ("track_number", EntityKind::Track) => Some("t.track_number"), + ("play_count", EntityKind::Track) => Some("t.play_count"), + ("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"), + ("year", EntityKind::Album) => Some("a.year"), + ("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"), + ("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"), + _ => None, + } +} + +fn json_to_text(field: &str, v: Option<&Value>) -> Result { + match v { + Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())), + _ => Err(filter::FilterError::BadValue { + field: field.to_string(), + detail: "expected a string value".to_string(), + } + .to_string()), + } +} + +fn json_to_opt_i64(field: &str, v: Option<&Value>) -> Result, String> { + match v { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(n)) => n + .as_i64() + .map(|i| Some(SqlValue::Integer(i))) + .ok_or_else(|| { + filter::FilterError::BadValue { + field: field.to_string(), + detail: "expected an integer value".to_string(), + } + .to_string() + }), + _ => Err(filter::FilterError::BadValue { + field: field.to_string(), + detail: "expected a numeric value".to_string(), + } + .to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::SortDir; + use crate::repos::{TrackRepository, TrackRow}; + use serde_json::json; + + // ── fixtures ─────────────────────────────────────────────────────── + + fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(format!("ar_{artist}")), + album: album.into(), + album_id: Some(format!("al_{album}")), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str, year: Option, genre: Option<&str>) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO album (server_id, id, name, year, genre, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, ?4, ?5, 1, '{}')", + rusqlite::params![server, id, name, year, genre], + ) + }) + .unwrap(); + } + + fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO artist (server_id, id, name, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, 1, '{}')", + rusqlite::params![server, id, name], + ) + }) + .unwrap(); + } + + fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest { + LibraryAdvancedSearchRequest { + server_id: server.into(), + library_scope: None, + query: None, + entity_types: entities.to_vec(), + filters: Vec::new(), + starred_only: None, + sort: Vec::new(), + limit: 50, + offset: 0, + skip_totals: false, + } + } + + fn clause(field: &str, op: FilterOp, value: Option, value_to: Option) -> LibraryFilterClause { + LibraryFilterClause { + field: field.into(), + op, + value, + value_to, + } + } + + // ── text / FTS ───────────────────────────────────────────────────── + + #[test] + fn text_prefix_query_matches_partial_artist_name() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Enter Sandman", "Metallica", "Metallica"), + track("s1", "t2", "Other", "Other Artist", "Other Album"), + ]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.query = Some("metal".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].artist.as_deref(), Some("Metallica")); + } + + #[test] + fn text_query_matches_track_via_fts() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Skylines"), + track("s1", "t2", "Sunset", "Beth", "Skylines"), + ]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.query = Some("aurora".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + assert_eq!(resp.totals.tracks, 1); + assert!(resp.applied_filters.contains(&"text".to_string())); + assert_eq!(resp.source, "local"); + } + + #[test] + fn text_query_matches_album_and_artist_via_like() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al1", "Aurora Nights", None, None); + insert_album(&store, "s1", "al2", "Other", None, None); + insert_artist(&store, "s1", "ar1", "Aurora Quartet"); + let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]); + r.query = Some("aurora".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al1"); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar1"); + } + + #[test] + fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() { + let store = LibraryStore::open_in_memory(); + let mut t1 = track("s1", "t1", "Song One", "Aurora Quartet", "Aurora Nights"); + t1.cover_art_id = Some("cv1".into()); + TrackRepository::new(&store) + .upsert_batch(&[ + t1, + track("s1", "t2", "Song Two", "Other Artist", "Other Album"), + ]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]); + r.query = Some("aurora".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al_Aurora Nights"); + assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1")); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar_Aurora Quartet"); + } + + #[test] + fn special_chars_in_query_do_not_crash_fts() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Hello World", "A", "B")]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + // Each of these is a raw FTS5 syntax error if passed unescaped; the + // builder must quote them into safe terms so the call returns Ok. + for q in ["\"", "AND", "foo*", "a OR b", "((", "near/"] { + r.query = Some(q.to_string()); + assert!( + run_advanced_search(&store, &r).is_ok(), + "query `{q}` must not raise an FTS syntax error" + ); + } + } + + #[test] + fn quoted_token_query_still_matches_clean_terms() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Hello World", "A", "B")]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + // Multi-token query AND-s its terms — both present → one hit. + r.query = Some("hello world".into()); + assert_eq!(run_advanced_search(&store, &r).unwrap().tracks.len(), 1); + } + + // ── genre / year / starred ───────────────────────────────────────── + + #[test] + fn genre_filter_is_case_insensitive() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.genre = Some("Ambient".into()); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.genre = Some("Techno".into()); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("genre", FilterOp::Eq, Some(json!("ambient")), None)]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + assert!(resp.applied_filters.contains(&"genre".to_string())); + } + + #[test] + fn year_between_is_inclusive() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.year = Some(2000); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.year = Some(2010); + let mut c = track("s1", "t3", "C", "X", "Alb"); + c.year = Some(2011); + TrackRepository::new(&store).upsert_batch(&[a, b, c]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("year", FilterOp::Between, Some(json!(2000)), Some(json!(2010)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["t1", "t2"]); + } + + #[test] + fn year_only_branch_runs_without_fts() { + // Genre/year-only (no query) must not require an FTS join (§5.13.7). + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.year = Some(1999); + TrackRepository::new(&store).upsert_batch(&[a]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("year", FilterOp::Gte, Some(json!(1999)), None)]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert!(!resp.applied_filters.contains(&"text".to_string())); + } + + #[test] + fn starred_only_filters_tracks() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.starred_at = Some(123); + let b = track("s1", "t2", "B", "X", "Alb"); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.starred_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + // ── bpm dual storage ─────────────────────────────────────────────── + + #[test] + fn bpm_filter_matches_hot_column() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.bpm = Some(125); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.bpm = Some(90); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + #[test] + fn bpm_filter_falls_back_to_track_fact() { + let store = LibraryStore::open_in_memory(); + // No hot `bpm`; an analysis fact carries it instead. + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "A", "X", "Alb")]) + .unwrap(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track_fact \ + (server_id, track_id, fact_kind, value_int, source_kind, source_id, confidence, fetched_at) \ + VALUES ('s1', 't1', 'bpm', 128, 'analysis', 'seed', 1.0, 1)", + [], + ) + }) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1, "bpm should resolve via track_fact fallback"); + } + + // ── entity routing / errors ──────────────────────────────────────── + + #[test] + fn track_only_filter_is_ignored_for_album_entity_no_error() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al1", "Some Album", Some(2001), None); + let mut r = req("s1", &[EntityKind::Album]); + // bpm is track-only; for an album query it must be skipped, not error. + r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))]; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert!(!resp.applied_filters.contains(&"bpm".to_string())); + } + + #[test] + fn unknown_field_is_an_error() { + let store = LibraryStore::open_in_memory(); + let mut r = req("s1", &[EntityKind::Track]); + r.filters = vec![clause("nope", FilterOp::Eq, Some(json!("x")), None)]; + let err = run_advanced_search(&store, &r).unwrap_err(); + assert!(err.contains("unknown filter field"), "got: {err}"); + } + + #[test] + fn planned_but_unbuilt_field_is_an_error() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "A", "X", "Alb")]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + // `suffix` is registered (Planned) but has no v1 SQL builder. + r.filters = vec![clause("suffix", FilterOp::Eq, Some(json!("flac")), None)]; + let err = run_advanced_search(&store, &r).unwrap_err(); + assert!(err.contains("not queryable"), "got: {err}"); + } + + #[test] + fn undeclared_op_for_known_field_is_an_error() { + let store = LibraryStore::open_in_memory(); + let mut r = req("s1", &[EntityKind::Track]); + // `genre` only declares `eq`. + r.filters = vec![clause("genre", FilterOp::Gte, Some(json!("rock")), None)]; + let err = run_advanced_search(&store, &r).unwrap_err(); + assert!(err.contains("not supported"), "got: {err}"); + } + + // ── scope / pagination / totals ──────────────────────────────────── + + #[test] + fn library_scope_narrows_track_results() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.library_id = Some("lib1".into()); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.library_id = Some("lib2".into()); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.library_scope = Some("lib1".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + #[test] + fn library_scope_reads_library_id_from_raw_json_when_column_null() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.raw_json = serde_json::json!({"libraryId": 3}).to_string(); + TrackRepository::new(&store).upsert_batch(&[a]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.library_scope = Some("3".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + #[test] + fn totals_reflect_full_match_count_not_page_size() { + let store = LibraryStore::open_in_memory(); + let rows: Vec = (0..10) + .map(|i| track("s1", &format!("t{i}"), "Common Title", "X", "Alb")) + .collect(); + TrackRepository::new(&store).upsert_batch(&rows).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.query = Some("common".into()); + r.limit = 3; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.tracks.len(), 3, "page is capped by limit"); + assert_eq!(resp.totals.tracks, 10, "total is the full match count"); + } + + #[test] + fn offset_pages_through_results() { + let store = LibraryStore::open_in_memory(); + let rows: Vec = (0..5) + .map(|i| track("s1", &format!("t{i}"), &format!("Title {i}"), "X", "Alb")) + .collect(); + TrackRepository::new(&store).upsert_batch(&rows).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.sort = vec![LibrarySortClause { field: "title".into(), dir: SortDir::Asc }]; + r.limit = 2; + r.offset = 2; + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["t2", "t3"]); + assert_eq!(resp.totals.tracks, 5); + } + + #[test] + fn unrequested_entities_are_empty() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "A", "X", "Alb")]) + .unwrap(); + insert_album(&store, "s1", "al1", "Alb", None, None); + let resp = run_advanced_search(&store, &req("s1", &[EntityKind::Track])).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert!(resp.albums.is_empty()); + assert!(resp.artists.is_empty()); + assert_eq!(resp.totals.albums, 0); + } + + #[test] + fn sort_desc_orders_results() { + let store = LibraryStore::open_in_memory(); + let mut a = track("s1", "t1", "A", "X", "Alb"); + a.year = Some(2000); + let mut b = track("s1", "t2", "B", "X", "Alb"); + b.year = Some(2020); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let mut r = req("s1", &[EntityKind::Track]); + r.sort = vec![LibrarySortClause { field: "year".into(), dir: SortDir::Desc }]; + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["t2", "t1"]); + } +} diff --git a/src-tauri/crates/psysonic-library/src/bulk_ingest.rs b/src-tauri/crates/psysonic-library/src/bulk_ingest.rs new file mode 100644 index 00000000..428d4fe3 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/bulk_ingest.rs @@ -0,0 +1,84 @@ +//! IS-3 bulk ingest tuning — drop hot-path indexes, restore at end. +//! +//! Secondary `track` indexes (album/artist/remap/…) are useless during the +//! initial upsert-only pass but cost several index inserts per row. Dropping +//! them for IS-3 keeps batch writes flat into the tens-of-ms range on large +//! libraries; they are recreated once before FTS rebuild. + +use rusqlite::Connection; + +const DROP_TRACK_SECONDARY_INDEXES: &str = r#" +DROP INDEX IF EXISTS idx_track_album; +DROP INDEX IF EXISTS idx_track_artist; +DROP INDEX IF EXISTS idx_track_updated; +DROP INDEX IF EXISTS idx_track_starred; +DROP INDEX IF EXISTS idx_track_library; +DROP INDEX IF EXISTS idx_track_bpm; +DROP INDEX IF EXISTS idx_track_isrc; +DROP INDEX IF EXISTS idx_track_remap_path; +DROP INDEX IF EXISTS idx_track_remap_hash; +DROP INDEX IF EXISTS idx_track_title; +"#; + +const RESTORE_TRACK_SECONDARY_INDEXES: &str = r#" +CREATE INDEX IF NOT EXISTS idx_track_album ON track(server_id, album_id) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_starred ON track(server_id, starred_at) WHERE deleted = 0 AND starred_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_track_library ON track(server_id, library_id) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_track_bpm ON track(server_id, bpm) WHERE deleted = 0 AND bpm IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_track_isrc ON track(isrc) WHERE deleted = 0 AND isrc IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_track_remap_path + ON track(server_id, server_path) + WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != ''; +CREATE INDEX IF NOT EXISTS idx_track_remap_hash + ON track(server_id, content_hash) + WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != ''; +CREATE INDEX IF NOT EXISTS idx_track_title + ON track(server_id, title COLLATE NOCASE) + WHERE deleted = 0; +"#; + +/// Drop secondary indexes on `track` so bulk upserts only touch the PK. +pub fn suspend_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(DROP_TRACK_SECONDARY_INDEXES) +} + +/// Recreate secondary indexes after bulk ingest (may take tens of seconds on +/// very large libraries — runs once at the end of IS-3, not per batch). +pub fn restore_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(RESTORE_TRACK_SECONDARY_INDEXES) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + #[test] + fn suspend_and_restore_track_indexes_roundtrip() { + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("misc", |conn| { + suspend_track_secondary_indexes(conn)?; + conn.execute( + "INSERT INTO track (server_id, id, title, album, album_id, artist_id, \ + duration_sec, deleted, synced_at, raw_json) \ + VALUES ('s1', 't1', 'T', 'Al', 'al1', 'ar1', 1, 0, 1, '{}')", + [], + )?; + restore_track_secondary_indexes(conn) + }) + .unwrap(); + let n: i64 = store + .with_read_conn(|c| { + c.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = 's1' AND album_id = 'al1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(n, 1); + } +} diff --git a/src-tauri/crates/psysonic-library/src/canonical.rs b/src-tauri/crates/psysonic-library/src/canonical.rs new file mode 100644 index 00000000..762a0405 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/canonical.rs @@ -0,0 +1,264 @@ +//! H1 — `CanonicalMatcher`: cross-server track identity by **strong keys +//! only** (ISRC, then MBID recording), per spec §5.5A / P13. +//! +//! Runs inline on ingest and is O(1) per track — a deterministic canonical +//! id (`{kind}:{value}`) plus `INSERT … ON CONFLICT DO NOTHING`, so there's +//! no lookup-then-create race and no fuzzy loop on the 500k upsert path +//! (the fuzzy / album-similarity layer is search-time only — §5.5B / H3). +//! +//! v1 does **not** merge across kinds (a recording seen with an ISRC on one +//! server and only an MBID on another yields two canonical rows). Spec §5.5A +//! accepts this; a future merge layer would remap ids without a schema break. + +use rusqlite::{params, OptionalExtension, Transaction}; + +pub const KIND_ISRC: &str = "isrc"; +pub const KIND_MBID: &str = "mbid_recording"; + +/// Resolve (create-if-absent) the canonical id for one strong identity. +/// Idempotent: the deterministic id means re-running over an existing +/// identity is a pair of no-op upserts. +pub fn resolve_canonical_id( + tx: &Transaction<'_>, + kind: &str, + value: &str, + now: i64, +) -> rusqlite::Result { + let canonical_id = format!("{kind}:{value}"); + tx.execute( + "INSERT INTO canonical_track (id, created_at, updated_at) VALUES (?1, ?2, ?2) \ + ON CONFLICT(id) DO NOTHING", + params![canonical_id, now], + )?; + tx.execute( + "INSERT INTO canonical_identity (canonical_id, kind, value, confidence) \ + VALUES (?1, ?2, ?3, 1.0) \ + ON CONFLICT(kind, value) DO NOTHING", + params![canonical_id, kind, value], + )?; + Ok(canonical_id) +} + +/// Link `(server_id, track_id)` to its canonical id for whichever strong key +/// it carries — ISRC preferred, then MBID recording (§5.5A). No-op (returns +/// `Ok(None)`) when the track has neither. Idempotent on re-ingest. +pub fn link_track( + tx: &Transaction<'_>, + server_id: &str, + track_id: &str, + isrc: Option<&str>, + mbid_recording: Option<&str>, + now: i64, +) -> rusqlite::Result> { + let (kind, value) = match ( + isrc.filter(|s| !s.is_empty()), + mbid_recording.filter(|s| !s.is_empty()), + ) { + (Some(isrc), _) => (KIND_ISRC, isrc), + (None, Some(mbid)) => (KIND_MBID, mbid), + (None, None) => return Ok(None), + }; + + let canonical_id = resolve_canonical_id(tx, kind, value, now)?; + tx.execute( + "INSERT INTO track_canonical_link \ + (server_id, track_id, canonical_id, match_method, confidence, linked_at) \ + VALUES (?1, ?2, ?3, ?4, 1.0, ?5) \ + ON CONFLICT(server_id, track_id) DO UPDATE SET \ + canonical_id = excluded.canonical_id, \ + match_method = excluded.match_method, \ + confidence = excluded.confidence, \ + linked_at = excluded.linked_at", + params![server_id, track_id, canonical_id, kind, now], + )?; + Ok(Some(canonical_id)) +} + +/// Link every track on `server_id` that carries a strong identity key. +/// Used once after IS-3 bulk ingest instead of per-row inline linking. +pub fn link_all_tracks_for_server( + store: &crate::store::LibraryStore, + server_id: &str, + now: i64, +) -> Result { + store.with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let mut stmt = tx.prepare( + "SELECT id, isrc, mbid_recording FROM track \ + WHERE server_id = ?1 AND deleted = 0 \ + AND (\ + (isrc IS NOT NULL AND isrc != '') \ + OR (mbid_recording IS NOT NULL AND mbid_recording != '')\ + )", + )?; + let rows: Vec<(String, Option, Option)> = stmt + .query_map(params![server_id], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })? + .collect::>>()?; + drop(stmt); + let mut linked = 0u32; + for (track_id, isrc, mbid) in rows { + if link_track( + &tx, + server_id, + &track_id, + isrc.as_deref(), + mbid.as_deref(), + now, + )? + .is_some() + { + linked = linked.saturating_add(1); + } + } + tx.commit()?; + Ok(linked) + }) +} + +/// Read a track's canonical id, if linked. Convenience for tests / callers. +pub fn canonical_id_for( + tx: &Transaction<'_>, + server_id: &str, + track_id: &str, +) -> rusqlite::Result> { + tx.query_row( + "SELECT canonical_id FROM track_canonical_link WHERE server_id = ?1 AND track_id = ?2", + params![server_id, track_id], + |r| r.get(0), + ) + .optional() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + fn with_tx(store: &LibraryStore, f: impl FnOnce(&Transaction<'_>) -> R) -> R { + store + .with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let out = f(&tx); + tx.commit()?; + Ok(out) + }) + .unwrap() + } + + /// `track_canonical_link` has an FK to `track` — seed a minimal row so + /// the link insert is valid (foreign_keys are ON in-memory). + fn seed_track(tx: &Transaction<'_>, server: &str, id: &str) { + tx.execute( + "INSERT INTO track (server_id, id, title, synced_at, raw_json) \ + VALUES (?1, ?2, 'T', 1, '{}')", + params![server, id], + ) + .unwrap(); + } + + #[test] + fn resolve_is_deterministic_and_idempotent() { + let store = LibraryStore::open_in_memory(); + let (a, b) = with_tx(&store, |tx| { + let a = resolve_canonical_id(tx, KIND_ISRC, "USRC123", 1).unwrap(); + let b = resolve_canonical_id(tx, KIND_ISRC, "USRC123", 2).unwrap(); + (a, b) + }); + assert_eq!(a, "isrc:USRC123"); + assert_eq!(a, b, "same identity → same canonical id"); + + let (tracks, identities): (i64, i64) = store + .with_conn("misc", |c| { + Ok(( + c.query_row("SELECT COUNT(*) FROM canonical_track", [], |r| r.get(0))?, + c.query_row("SELECT COUNT(*) FROM canonical_identity", [], |r| r.get(0))?, + )) + }) + .unwrap(); + assert_eq!(tracks, 1, "idempotent — no duplicate canonical_track"); + assert_eq!(identities, 1); + } + + #[test] + fn link_prefers_isrc_over_mbid() { + let store = LibraryStore::open_in_memory(); + let cid = with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + link_track(tx, "s1", "t1", Some("USRC1"), Some("mbid-1"), 1).unwrap() + }); + assert_eq!(cid.as_deref(), Some("isrc:USRC1")); + let stored = + with_tx(&store, |tx| canonical_id_for(tx, "s1", "t1").unwrap()); + assert_eq!(stored.as_deref(), Some("isrc:USRC1")); + } + + #[test] + fn link_uses_mbid_when_no_isrc() { + let store = LibraryStore::open_in_memory(); + let cid = with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + link_track(tx, "s1", "t1", None, Some("mbid-9"), 1).unwrap() + }); + assert_eq!(cid.as_deref(), Some("mbid_recording:mbid-9")); + } + + #[test] + fn link_is_noop_without_strong_keys() { + let store = LibraryStore::open_in_memory(); + let cid = with_tx(&store, |tx| { + // empty strings count as absent + link_track(tx, "s1", "t1", Some(""), None, 1).unwrap() + }); + assert!(cid.is_none()); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn same_isrc_across_servers_shares_one_canonical_id() { + let store = LibraryStore::open_in_memory(); + let (a, b) = with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + seed_track(tx, "s2", "t9"); + let a = link_track(tx, "s1", "t1", Some("USRC7"), None, 1).unwrap(); + let b = link_track(tx, "s2", "t9", Some("USRC7"), None, 1).unwrap(); + (a, b) + }); + assert_eq!(a, b); + let canon_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM canonical_track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(canon_count, 1, "one canonical row shared across two servers"); + } + + #[test] + fn relink_updates_existing_row_in_place() { + let store = LibraryStore::open_in_memory(); + with_tx(&store, |tx| { + seed_track(tx, "s1", "t1"); + link_track(tx, "s1", "t1", None, Some("mbid-old"), 1).unwrap(); + // Later ingest now carries an ISRC — re-link overwrites. + link_track(tx, "s1", "t1", Some("USRCnew"), Some("mbid-old"), 2).unwrap(); + }); + let (cid, method): (String, String) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT canonical_id, match_method FROM track_canonical_link \ + WHERE server_id = 's1' AND track_id = 't1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!(cid, "isrc:USRCnew"); + assert_eq!(method, "isrc"); + let link_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0))) + .unwrap(); + assert_eq!(link_count, 1, "re-link updates in place, no duplicate"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs new file mode 100644 index 00000000..7e10307b --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -0,0 +1,1397 @@ +//! Tauri commands — read-only surface for PR-5a (spec §7.1). Mutating +//! commands + sync lifecycle land in PR-5b. All commands take a +//! `State` so the top crate's `setup()` can wire one +//! shared `Arc` across the whole IPC surface. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rusqlite::params; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Manager, State}; + +use psysonic_integration::navidrome::navidrome_token; +use psysonic_integration::subsonic::SubsonicClient; + +use crate::advanced_search; +use crate::cross_server; +use crate::dto::{ + count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, + FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, + LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto, + LibraryTracksEnvelope, OfflinePathDto, PurgeReportDto, SyncJobDto, SyncStateDto, + TrackArtifactDto, TrackFactDto, TrackRefDto, +}; +use crate::live_search; +use crate::payload::LibrarySyncProgressPayload; +use crate::repos::{SyncStateRepository, TrackRepository}; +use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession}; +use crate::search::search_tracks; +use crate::store::LibraryStore; +use crate::sync::bandwidth::PlaybackHint; +use crate::sync::bandwidth::ParallelismBudget; +use crate::sync::capability::{probe_and_persist, CapabilityFlags, NavidromeProbeCredentials}; +use crate::sync::delta::DeltaSyncRunner; +use crate::sync::error::SyncError; +use crate::sync::initial::InitialSyncRunner; +use crate::sync::progress::{ChannelProgress, Progress, ProgressEvent}; +use crate::sync::tombstone::should_auto_reconcile; + +/// Cap for `library_get_tracks_batch` per spec §7.1 ("max 100 refs/call"). +const TRACKS_BATCH_LIMIT: usize = 100; + +#[tauri::command] +pub async fn library_get_status( + runtime: State<'_, LibraryRuntime>, + server_id: String, + library_scope: Option, +) -> Result { + let scope = library_scope.unwrap_or_default(); + let row: Option = runtime + .store + .with_read_conn(|conn| { + conn.query_row( + "SELECT sync_phase, capability_flags, library_tier, last_full_sync_at, \ + last_delta_sync_at, next_poll_at, server_last_scan_iso, \ + indexes_last_modified_ms, artists_last_modified_ms, local_track_count, \ + server_track_count, last_error \ + FROM sync_state WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, scope], + |r| { + Ok(SyncStateRow { + sync_phase: r.get(0)?, + capability_flags: r.get::<_, i64>(1)?.max(0) as u32, + library_tier: r.get(2)?, + last_full_sync_at: r.get(3)?, + last_delta_sync_at: r.get(4)?, + next_poll_at: r.get(5)?, + server_last_scan_iso: r.get(6)?, + indexes_last_modified_ms: r.get(7)?, + artists_last_modified_ms: r.get(8)?, + local_track_count: r.get(9)?, + server_track_count: r.get(10)?, + last_error: r.get(11)?, + }) + }, + ) + .optional() + }) + .map_err(|e| e.to_string())?; + + let local_tracks_max_updated_ms = if row.as_ref().is_some_and(|r| r.sync_phase == "initial_sync") { + None + } else { + local_tracks_max_updated_ms(&runtime.store, &server_id)? + }; + let has_local_tracks = track_index_nonempty(&runtime.store, &server_id).unwrap_or(false); + let sync_state = SyncStateRepository::new(&runtime.store); + let (ingest_strategy, ingest_phase, cursor_ingested_count) = sync_state + .get_initial_sync_cursor(&server_id, &scope) + .ok() + .flatten() + .map(|v| parse_ingest_cursor(&v)) + .unwrap_or((None, None, None)); + let n1_bulk_unreliable = sync_state + .get_n1_bulk_unreliable(&server_id, &scope) + .ok() + .flatten(); + let row = row.unwrap_or_default(); + let local_track_count = resolve_local_track_count( + &row, + cursor_ingested_count, + has_local_tracks, + &runtime.store, + &server_id, + ); + // `SyncStateRepository::ensure` is intentionally NOT called from + // the read path — `library_get_status` on a fresh server returns + // an "idle / unknown" stub without writing a row. PR-5b writes + // the row when `bind_session` lands. + Ok(SyncStateDto { + server_id, + library_scope: scope, + sync_phase: row.sync_phase, + capability_flags: row.capability_flags, + library_tier: row.library_tier, + last_full_sync_at: row.last_full_sync_at, + last_delta_sync_at: row.last_delta_sync_at, + next_poll_at: row.next_poll_at, + server_last_scan_iso: row.server_last_scan_iso, + indexes_last_modified_ms: row.indexes_last_modified_ms, + artists_last_modified_ms: row.artists_last_modified_ms, + local_track_count, + server_track_count: row.server_track_count, + last_error: row.last_error, + local_tracks_max_updated_ms, + has_local_tracks, + ingest_strategy, + ingest_phase, + cursor_ingested_count, + n1_bulk_unreliable, + }) +} + +fn parse_ingest_cursor(raw: &Value) -> (Option, Option, Option) { + if raw.as_object().is_none_or(|o| o.is_empty()) { + return (None, None, None); + } + let strategy = raw + .get("strategy") + .and_then(|v| v.as_str()) + .map(str::to_string); + let phase = raw + .get("phase") + .and_then(|v| v.as_str()) + .map(str::to_string); + let ingested = raw + .get("ingested_count") + .and_then(|v| v.as_u64()) + .map(|n| n.min(u32::MAX as u64) as u32); + (strategy, phase, ingested) +} + +/// Avoid full-table `COUNT(*)` while `initial_sync` is writing — use the +/// cheap cursor / snapshot counters updated on each cursor persist instead. +fn resolve_local_track_count( + row: &SyncStateRow, + cursor_ingested_count: Option, + has_local_tracks: bool, + store: &LibraryStore, + server_id: &str, +) -> Option { + if row.sync_phase == "initial_sync" { + let snapshot = row.local_track_count.unwrap_or(0); + let cursor = cursor_ingested_count.map(i64::from).unwrap_or(0); + let best = snapshot.max(cursor); + return if best > 0 { Some(best) } else { row.local_track_count }; + } + match row.local_track_count { + Some(n) if n > 0 => Some(n), + _ if has_local_tracks => count_local_tracks(store, server_id).ok(), + _ => row.local_track_count, + } +} + +#[tauri::command] +pub async fn library_search( + runtime: State<'_, LibraryRuntime>, + server_id: String, + query: String, + limit: Option, + offset: Option, + library_scope: Option, +) -> Result { + let _ = library_scope; // PR-5a accepts the arg for forward-compat; filter is wired in §5.13 + let limit = limit.unwrap_or(100).clamp(1, 500); + let offset = offset.unwrap_or(0); + // `search_tracks` returns lean `TrackHit` rows for FTS; PR-5a + // re-fetches the full `TrackRow` per hit so the DTO carries every + // hot column. Acceptable for `limit ≤ 100`; PR-5d wires a single- + // statement SQL builder via the FilterRegistry. + let hits = search_tracks(&runtime.store, &server_id, &query, limit as i64 + offset as i64)?; + let mut paged: Vec = hits + .into_iter() + .skip(offset as usize) + .map(|h| TrackRefDto { + server_id: h.server_id, + track_id: h.id, + content_hash: None, + }) + .collect(); + paged.truncate(limit as usize); + + let total = paged.len() as u32; + let tracks = hydrate_refs(&runtime, &paged)?; + Ok(LibraryTracksEnvelope { tracks, total }) +} + +#[tauri::command] +pub async fn library_get_track( + runtime: State<'_, LibraryRuntime>, + app: AppHandle, + server_id: String, + track_id: String, +) -> Result, String> { + let repo = TrackRepository::new(&runtime.store); + let Some(row) = repo.find_one(&server_id, &track_id)? else { + return Ok(None); + }; + let mut dto = LibraryTrackDto::from_row(&row); + + // E3 enrichment (read-only, per-server, best-effort — never blocks on the + // network). Only the single-track read pays for this; list/batch projections + // leave `enrichment = None`. + let now = now_unix_ms(); + let lyrics_cached = crate::repos::ArtifactRepository::new(&runtime.store) + .lyrics_cached(&server_id, &track_id, now) + .unwrap_or(false); + // waveform/loudness readiness is gated on a known content_hash (md5_16kb, + // populated by E2) and probed via the analysis-readiness port — exact key + // then legacy '' fallback, no re-tag. Absent port or hash ⇒ not ready. + let (waveform_ready, loudness_ready) = + match row.content_hash.as_deref().filter(|s| !s.is_empty()) { + Some(md5) => app + .try_state::() + .map(|q| q.readiness(&server_id, &track_id, md5)) + .unwrap_or((false, false)), + None => (false, false), + }; + dto.enrichment = Some(crate::dto::TrackEnrichmentDto { + waveform_ready, + loudness_ready, + lyrics_cached, + }); + Ok(Some(dto)) +} + +#[tauri::command] +pub async fn library_get_tracks_batch( + runtime: State<'_, LibraryRuntime>, + refs: Vec, +) -> Result, String> { + if refs.len() > TRACKS_BATCH_LIMIT { + return Err(format!( + "library_get_tracks_batch: refs exceeds cap ({} > {})", + refs.len(), + TRACKS_BATCH_LIMIT + )); + } + hydrate_refs(&runtime, &refs) +} + +#[tauri::command] +pub async fn library_get_tracks_by_album( + runtime: State<'_, LibraryRuntime>, + server_id: String, + album_id: String, +) -> Result, String> { + let rows = TrackRepository::new(&runtime.store).find_by_album(&server_id, &album_id)?; + Ok(rows.iter().map(LibraryTrackDto::from_row).collect()) +} + +#[tauri::command] +pub async fn library_get_artifact( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + artifact_kind: String, + source_kind: Option, + source_id: Option, + format: Option, +) -> Result, String> { + // E4: typed repo owns the §5.12 lazy-expiry + flexible lookup. + crate::repos::ArtifactRepository::new(&runtime.store).get( + &server_id, + &track_id, + &artifact_kind, + source_kind.as_deref(), + source_id.as_deref(), + format.as_deref(), + now_unix_ms(), + ) +} + +#[tauri::command] +pub async fn library_get_facts( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + fact_kinds: Option>, +) -> Result, String> { + // E4: typed repo owns the §5.12 lazy-expiry + provenance rules. + crate::repos::FactRepository::new(&runtime.store).get( + &server_id, + &track_id, + &fact_kinds.unwrap_or_default(), + now_unix_ms(), + ) +} + +#[tauri::command] +pub async fn library_get_offline_path( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, +) -> Result { + let path = runtime + .store + .with_conn("misc", |conn| { + conn.query_row( + "SELECT local_path FROM track_offline \ + WHERE server_id = ?1 AND track_id = ?2", + params![server_id, track_id], + |row| row.get::<_, String>(0), + ) + .optional() + }) + .map_err(|e| e.to_string())?; + Ok(OfflinePathDto { + server_id, + track_id, + missing: path.is_none(), + local_path: path, + }) +} + +// ────────────────────────────────────────────────────────────────────── +// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B) +// ────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn library_advanced_search( + runtime: State<'_, LibraryRuntime>, + request: LibraryAdvancedSearchRequest, +) -> Result { + advanced_search::run_advanced_search(&runtime.store, &request) +} + +#[tauri::command] +pub async fn library_live_search( + runtime: State<'_, LibraryRuntime>, + request: LibraryLiveSearchRequest, +) -> Result { + let empty = || LibraryLiveSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + source: "local".to_string(), + }; + if let Some(epoch) = request.request_epoch { + runtime.register_live_search_epoch(epoch); + if !runtime.live_search_still_current(epoch) { + return Ok(empty()); + } + } + let result = live_search::run_live_search( + &runtime.store, + &request.server_id, + &request.query, + request.library_scope.as_deref(), + request.artist_limit.unwrap_or(5), + request.album_limit.unwrap_or(5), + request.song_limit.unwrap_or(10), + )?; + if request + .request_epoch + .is_some_and(|epoch| !runtime.live_search_still_current(epoch)) + { + return Ok(empty()); + } + Ok(result) +} + +#[tauri::command] +pub async fn library_search_cross_server( + runtime: State<'_, LibraryRuntime>, + query: String, + limit: Option, + servers: Option>, +) -> Result { + let limit = limit.unwrap_or(100); + cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref()) +} + +// ── helpers ────────────────────────────────────────────────────────── + +fn hydrate_refs( + runtime: &LibraryRuntime, + refs: &[TrackRefDto], +) -> Result, String> { + let pairs: Vec<(String, String)> = refs + .iter() + .map(|r| (r.server_id.clone(), r.track_id.clone())) + .collect(); + let rows = TrackRepository::new(&runtime.store).find_batch(&pairs)?; + Ok(rows.iter().map(LibraryTrackDto::from_row).collect()) +} + +#[derive(Default)] +struct SyncStateRow { + sync_phase: String, + capability_flags: u32, + library_tier: String, + last_full_sync_at: Option, + last_delta_sync_at: Option, + next_poll_at: Option, + server_last_scan_iso: Option, + indexes_last_modified_ms: Option, + artists_last_modified_ms: Option, + local_track_count: Option, + server_track_count: Option, + last_error: Option, +} + +use rusqlite::OptionalExtension; + +// ────────────────────────────────────────────────────────────────────── +// PR-5b — session / lifecycle / mutate / purge +// ────────────────────────────────────────────────────────────────────── + +/// Normalise a server URL the same way the frontend's +/// `authStore.getBaseUrl()` does — prepend `http://` when no scheme is +/// present and strip the trailing slash. `server.url` is stored bare +/// (e.g. `nas.example.com`); without this reqwest rejects the request +/// with "relative URL without a base". +fn normalize_base_url(raw: &str) -> String { + let trimmed = raw.trim(); + let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + with_scheme.trim_end_matches('/').to_string() +} + +/// Acquire a Navidrome native-API bearer with a few retries. `/auth/login` +/// is occasionally flaky; one transient miss must not strip N1 for the whole +/// session (R7-15 Q3). Returns `None` only after every attempt fails — the +/// caller falls back to a cached bearer / the Subsonic-only path. Never logs +/// the token or credentials. +async fn navidrome_token_with_retry( + base_url: &str, + username: &str, + password: &str, +) -> Option { + const ATTEMPTS: u32 = 3; + for attempt in 1..=ATTEMPTS { + match navidrome_token(base_url, username, password).await { + Ok(tok) => return Some(tok), + Err(_) if attempt < ATTEMPTS => { + tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await; + } + Err(_) => return None, + } + } + None +} + +#[tauri::command] +pub async fn library_sync_bind_session( + runtime: State<'_, LibraryRuntime>, + server_id: String, + base_url: String, + username: String, + password: String, + library_scope: Option, +) -> Result<(), String> { + let base_url = normalize_base_url(&base_url); + // Prime the Navidrome native-API bearer at bind time (spec §6.1 + PR-5 + // kickoff Q5) so N1 probe / ingest works without every command passing a + // token. `/auth/login` is flaky, so retry a few times; if it still fails, + // keep a bearer cached from a prior bind rather than dropping to + // Subsonic-only — a transient miss must not strip an N1-capable server + // (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic. + let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password) + .await + { + Some(tok) => Some(tok), + None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token), + }; + + let session = SyncSession { + server_id: server_id.clone(), + base_url: base_url.clone(), + username: username.clone(), + password: password.clone(), + navidrome_token: navidrome_token_cached.clone(), + library_scope: library_scope.clone(), + }; + runtime.set_session(session); + + // Run the probe + persist capability flags. Failure to probe is a + // bind-time error — caller should fix credentials / URL. + let subsonic = SubsonicClient::new(base_url, username, password); + let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials { + server_url: subsonic_base_url_from(&runtime, &server_id), + bearer_token: tok, + }); + let scope = library_scope.as_deref().unwrap_or_default(); + probe_and_persist( + &runtime.store, + &subsonic, + navidrome_creds.as_ref(), + &server_id, + scope, + ) + .await + .map_err(|e| format!("bind probe failed: {e}"))?; + Ok(()) +} + +fn subsonic_base_url_from(runtime: &LibraryRuntime, server_id: &str) -> String { + runtime + .get_session(server_id) + .map(|s| s.base_url) + .unwrap_or_default() +} + +#[tauri::command] +pub fn library_sync_clear_session( + runtime: State<'_, LibraryRuntime>, + server_id: String, +) -> Result<(), String> { + runtime.clear_session(&server_id); + Ok(()) +} + +#[tauri::command] +pub fn library_set_playback_hint( + runtime: State<'_, LibraryRuntime>, + hint: String, +) -> Result<(), String> { + let parsed = match hint.as_str() { + "idle" => PlaybackHint::Idle, + "playing" => PlaybackHint::Playing, + "prefetch_active" => PlaybackHint::PrefetchActive, + other => return Err(format!("unknown playback hint: `{other}`")), + }; + runtime.set_playback_hint(parsed); + Ok(()) +} + +#[tauri::command] +pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result { + Ok(match runtime.current_playback_hint() { + PlaybackHint::Idle => "idle".to_string(), + PlaybackHint::Playing => "playing".to_string(), + PlaybackHint::PrefetchActive => "prefetch_active".to_string(), + }) +} + +#[tauri::command] +pub async fn library_sync_start( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + mode: String, + library_scope: Option, +) -> Result { + library_sync_start_inner(app, runtime, server_id, mode, library_scope, false).await +} + +/// Map a runner result for the sync-idle event. Cancellation is expected — +/// the user cancelled, or a newer `library_sync_start` superseded this job +/// (e.g. a server switch, or the startup resume) — and must never surface as +/// a failure toast (error.rs: "Cancelled is silent"). +fn sync_outcome_to_result(r: Result) -> Result<(), String> { + match r { + Ok(_) => Ok(()), + Err(SyncError::Cancelled) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +async fn library_sync_start_inner( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + mode: String, + library_scope: Option, + force_full_tombstone: bool, +) -> Result { + let session = runtime.get_session(&server_id).ok_or_else(|| { + format!("no bound session for server `{server_id}` — call library_sync_bind_session first") + })?; + let scope = library_scope.clone().or(session.library_scope.clone()).unwrap_or_default(); + let mut capability_flags = load_capability_flags(&runtime, &server_id, &scope)?; + // N1 needs the Navidrome bearer. Without a cached token this run is + // Subsonic-only even on an N1-capable server — mask the flag for *this* + // run's strategy selection (R7-15 Q3 "proceed as Subsonic-only"). The + // persisted server capability stays untouched, so a later bind that + // recovers the token can use N1 again. + if session.navidrome_token.is_none() { + capability_flags.remove(CapabilityFlags::NAVIDROME_NATIVE_BULK); + } + + let kind = match mode.as_str() { + "full" => "initial_sync", + "delta" => "delta_sync", + other => return Err(format!("unknown sync mode: `{other}`")), + }; + if let Some(existing) = runtime.current_job() { + if existing.kind == "initial_sync" { + match kind { + "initial_sync" if existing.server_id == server_id => { + // Same-server full resync: cancel and drain the in-flight + // runner so its cursor writes can't race the replacement. + let done = Arc::clone(&existing.done); + existing + .cancel + .store(true, std::sync::atomic::Ordering::SeqCst); + drop(existing); + done.notified().await; + } + "initial_sync" => { + return Err(format!( + "initial sync already running for `{}` — wait for it to finish", + existing.server_id + )); + } + _ => { + return Err(format!( + "initial sync in progress for `{}` — try again later", + existing.server_id + )); + } + } + } + } + let job_id = format!("{}_{}", server_id, now_unix_ms()); + let cancel = Arc::new(AtomicBool::new(false)); + let done = Arc::new(tokio::sync::Notify::new()); + let job = CurrentJob { + job_id: job_id.clone(), + server_id: server_id.clone(), + kind: kind.to_string(), + cancel: Arc::clone(&cancel), + done: Arc::clone(&done), + }; + runtime.set_current_job(job); + + // Spawn the runner in a detached task. Progress events flow + // through an mpsc channel to the orchestrator that emits Tauri + // events; the runner doesn't need an AppHandle. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let progress: Arc = + Arc::new(ChannelProgress::new(tx)); + + let store = Arc::clone(&runtime.store); + let session_clone = session.clone(); + let scope_for_task = scope.clone(); + let kind_for_task = kind.to_string(); + let cancel_for_task = Arc::clone(&cancel); + let job_id_for_task = job_id.clone(); + let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint()); + + let runner_handle: tokio::task::JoinHandle> = tokio::task::spawn(async move { + let subsonic = SubsonicClient::new( + session_clone.base_url.clone(), + session_clone.username.clone(), + session_clone.password.clone(), + ); + let navidrome_creds = session_clone.navidrome_token.clone().map(|tok| { + NavidromeProbeCredentials { + server_url: session_clone.base_url.clone(), + bearer_token: tok, + } + }); + + let result: Result<(), String> = if kind_for_task == "initial_sync" { + let mut runner = InitialSyncRunner::new( + &store, + &subsonic, + session_clone.server_id.clone(), + scope_for_task.clone(), + capability_flags, + ) + .with_cancellation(Arc::clone(&cancel_for_task)) + .with_progress(Arc::clone(&progress)) + .with_parallelism_budget(parallelism); + if let Some(creds) = navidrome_creds.clone() { + runner = runner.with_navidrome_credentials(creds); + } + sync_outcome_to_result(runner.run().await) + } else { + // Delta — Mode A manual integrity uses the DeltaMismatch + // budget for tombstones when the local/server count gap + // is over threshold; otherwise a small budget keeps the + // background-like pass cheap. Manual «Verify integrity» + // forces the full budget regardless of threshold. + let tombstone_budget = if force_full_tombstone { + crate::sync::budget::RequestBudget::DELTA_MISMATCH_CAP + } else { + compute_tombstone_budget(&store, &session_clone.server_id, &scope_for_task) + }; + let mut runner = DeltaSyncRunner::new( + &store, + &subsonic, + session_clone.server_id.clone(), + scope_for_task.clone(), + capability_flags, + ) + .with_cancellation(Arc::clone(&cancel_for_task)) + .with_progress(Arc::clone(&progress)); + if tombstone_budget > 0 { + runner = runner.with_tombstone_budget(tombstone_budget); + } + if let Some(creds) = navidrome_creds.clone() { + runner = runner.with_navidrome_credentials(creds); + } + sync_outcome_to_result(runner.run().await) + }; + + // Closing the mpsc sender by dropping `progress` so the + // orchestrator's drain loop terminates. + drop(progress); + let _ = job_id_for_task; // silence unused on Err + result + }); + + // Orchestrator: drain progress + emit Tauri events, then emit + // sync-idle when the runner exits. + let app_for_emit = app.clone(); + let server_id_for_emit = server_id.clone(); + let scope_for_emit = scope.clone(); + let kind_for_emit = kind.to_string(); + let job_id_for_emit = job_id.clone(); + tokio::task::spawn(async move { + // Drain progress events; loop ends when sender is dropped. + while let Some(event) = rx.recv().await { + let payload = LibrarySyncProgressPayload::from_event( + &event, + &server_id_for_emit, + &scope_for_emit, + ); + let _ = app_for_emit + .emit(LibrarySyncProgressPayload::PROGRESS_EVENT_NAME, &payload); + } + // Wait for the runner to finish + emit sync-idle. + let outcome = match runner_handle.await { + Ok(Ok(())) => SyncIdleAck::ok(&server_id_for_emit, &scope_for_emit, &kind_for_emit), + Ok(Err(msg)) => SyncIdleAck::err(&server_id_for_emit, &scope_for_emit, &kind_for_emit, &msg), + Err(join_err) => SyncIdleAck::err( + &server_id_for_emit, + &scope_for_emit, + &kind_for_emit, + &format!("sync task panicked: {join_err}"), + ), + }; + let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome); + + // Clear the slot only if it still names us — sync_start may + // have already overwritten with a newer job. + if let Some(state) = app_for_emit.try_state::() { + if let Some(job) = state.current_job() { + if job.job_id == job_id_for_emit { + job.done.notify_one(); + } + } + state.clear_current_job_if_matches(&job_id_for_emit); + } + }); + + Ok(SyncJobDto { + job_id, + server_id, + kind: kind.to_string(), + }) +} + +/// Manual «Verify library integrity» — same dispatch shape as +/// `library_sync_start { mode: 'delta' }` but always sets the full +/// `DELTA_MISMATCH_CAP` tombstone budget regardless of the +/// local/server count gap. Per PR-5b review §5 note 2: spec §6.7 +/// Mode A user-initiated full reconcile bypasses the threshold +/// check. +#[tauri::command] +pub async fn library_sync_verify_integrity( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + library_scope: Option, +) -> Result { + library_sync_start_inner( + app, + runtime, + server_id, + "delta".to_string(), + library_scope, + /* force_full_tombstone */ true, + ) + .await +} + +#[tauri::command] +pub fn library_sync_cancel( + runtime: State<'_, LibraryRuntime>, + job_id: Option, +) -> Result<(), String> { + // `job_id` is informational — there's at most one in-flight job + // per `LibraryRuntime` at a time. If it's supplied and doesn't + // match, treat as no-op (the named job already finished). + if let Some(id) = &job_id { + if runtime.current_job().is_none_or(|j| &j.job_id != id) { + return Ok(()); + } + } + runtime.cancel_current_job(); + Ok(()) +} + +/// Record the playback-derived `md5_16kb` as `track.content_hash` for +/// `(server_id, track_id)` (E2). A no-op when the value is empty or the library +/// has no row for that pair (index off for the server). Shared by the +/// analysis→library content_hash bridge (registered in the shell crate) and by +/// [`library_patch_track`]'s `contentHash` field. The playback hash is +/// authoritative, so this overwrites unconditionally; sync ingest preserves it +/// via `COALESCE(NULLIF(excluded.content_hash,''), …)` in the upsert. +pub fn patch_content_hash( + runtime: &LibraryRuntime, + server_id: &str, + track_id: &str, + md5_16kb: &str, +) -> Result<(), String> { + if md5_16kb.is_empty() { + return Ok(()); + } + runtime + .store + .with_conn("misc", |conn| { + conn.execute( + "UPDATE track SET content_hash = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, md5_16kb], + )?; + Ok(()) + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_patch_track( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + patch: Value, +) -> Result<(), String> { + apply_track_patch(&runtime, &server_id, &track_id, &patch) +} + +/// Apply a sparse `library_patch_track` JSON patch (extracted from the command +/// so it is unit-testable without a Tauri `State`). Only fields explicitly +/// present in `patch` are applied; absent keys leave the column untouched. For +/// the nullable integer fields, an explicit `null` clears the column (e.g. +/// `unstar` → `starredAt: null`): `.map` keeps the present/absent distinction +/// (outer `Some` = key present), `as_i64()` yields the value or `None` → bound +/// as SQL NULL. Spec §6.5 patch-on-use: `starred_at`, `user_rating`, +/// `play_count`, `played_at`; §8.1 E2: `content_hash`. All UPDATEs no-op when +/// the library has no row for `(server_id, track_id)`. +pub(crate) fn apply_track_patch( + runtime: &LibraryRuntime, + server_id: &str, + track_id: &str, + patch: &Value, +) -> Result<(), String> { + let starred_at = patch.get("starredAt").map(|v| v.as_i64()); + let user_rating = patch.get("userRating").map(|v| v.as_i64()); + let play_count = patch.get("playCount").map(|v| v.as_i64()); + let played_at = patch.get("playedAt").map(|v| v.as_i64()); + let content_hash = patch + .get("contentHash") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + + runtime + .store + .with_conn("misc", |conn| { + // One UPDATE per field present — keeps SQL simple and + // matches the spec's per-field patch semantics. + if let Some(v) = starred_at { + conn.execute( + "UPDATE track SET starred_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = user_rating { + conn.execute( + "UPDATE track SET user_rating = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = play_count { + conn.execute( + "UPDATE track SET play_count = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = played_at { + conn.execute( + "UPDATE track SET played_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + if let Some(v) = content_hash { + conn.execute( + "UPDATE track SET content_hash = ?3 \ + WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, v], + )?; + } + Ok(()) + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_put_artifact( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + artifact: ArtifactInputDto, +) -> Result<(), String> { + // E4: typed repo owns the upsert + the §5.12 512 KB size cap. + crate::repos::ArtifactRepository::new(&runtime.store).put( + &server_id, + &track_id, + &artifact, + now_unix_ms(), + ) +} + +#[tauri::command] +pub fn library_put_fact( + runtime: State<'_, LibraryRuntime>, + server_id: String, + track_id: String, + fact: FactInputDto, +) -> Result<(), String> { + // E4: typed repo owns the upsert + the §5.12 user-override rule + // (a `user` bpm fact also writes the hot `track.bpm` column). + crate::repos::FactRepository::new(&runtime.store).put(&server_id, &track_id, &fact, now_unix_ms()) +} + +#[tauri::command] +pub fn library_purge_server( + runtime: State<'_, LibraryRuntime>, + server_id: String, + include_analysis: Option, + include_offline: Option, +) -> Result { + // R7-16 Q7: `includeAnalysis` is a deliberate v1 no-op — analysis blobs are + // expensive to rebuild (full-file decode) and the same host may return under + // a new login / app server_id with identical file content, so a purge or + // server-remove never deletes waveform/loudness rows. Kept on the surface for + // forward compat; explicit cleanup stays Settings → Storage + queue reseed. + let _ = include_analysis; + let include_offline = include_offline.unwrap_or(false); + + let mut report = PurgeReportDto::default(); + runtime + .store + .with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let track_count: i64 = + tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let album_count: i64 = + tx.query_row("SELECT COUNT(*) FROM album WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let artist_count: i64 = + tx.query_row("SELECT COUNT(*) FROM artist WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let offline_count: i64 = + tx.query_row("SELECT COUNT(*) FROM track_offline WHERE server_id = ?1", params![server_id], |r| r.get(0))?; + let offline_bytes: Option = tx + .query_row( + "SELECT SUM(file_size_bytes) FROM track_offline WHERE server_id = ?1", + params![server_id], + |r| r.get(0), + ) + .ok(); + + // Tear down child rows first (no cascade configured) so + // the FK constraints on track stay happy. + tx.execute( + "DELETE FROM track_extension WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_fact WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_artifact WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_canonical_link WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track_id_history WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM track WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM album WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM artist WHERE server_id = ?1", + params![server_id], + )?; + tx.execute( + "DELETE FROM sync_state WHERE server_id = ?1", + params![server_id], + )?; + if include_offline { + tx.execute( + "DELETE FROM track_offline WHERE server_id = ?1", + params![server_id], + )?; + } + tx.commit()?; + + report.tracks_deleted = track_count.max(0) as u32; + report.albums_deleted = album_count.max(0) as u32; + report.artists_deleted = artist_count.max(0) as u32; + report.offline_rows_deleted = if include_offline { + offline_count.max(0) as u32 + } else { + 0 + }; + report.bytes_freed = if include_offline { + offline_bytes.unwrap_or(0).max(0) + } else { + 0 + }; + Ok(()) + }) + .map_err(|e| e.to_string())?; + + // Drop any bound session / current job for this server — credentials + // out of memory, ongoing job cancelled. + runtime.clear_session(&server_id); + if let Some(job) = runtime.current_job() { + if job.server_id == server_id { + job.cancel.store(true, Ordering::SeqCst); + } + } + Ok(report) +} + +#[tauri::command] +pub fn library_delete_server_data( + runtime: State<'_, LibraryRuntime>, + server_id: String, +) -> Result<(), String> { + library_purge_server(runtime, server_id, Some(false), Some(true)).map(|_| ()) +} + +// ── helpers ────────────────────────────────────────────────────────── + +fn load_capability_flags( + runtime: &LibraryRuntime, + server_id: &str, + library_scope: &str, +) -> Result { + let bits = SyncStateRepository::new(&runtime.store) + .get_capability_flags(server_id, library_scope)? + .unwrap_or(0); + Ok(CapabilityFlags::new(bits)) +} + +fn compute_tombstone_budget( + store: &crate::store::LibraryStore, + server_id: &str, + library_scope: &str, +) -> u32 { + let sync_state = SyncStateRepository::new(store); + let local = sync_state + .get_local_track_count(server_id, library_scope) + .ok() + .flatten() + .unwrap_or(0) + .max(0) as u32; + let server = sync_state + .get_server_track_count(server_id, library_scope) + .ok() + .flatten() + .unwrap_or(0) + .max(0) as u32; + if should_auto_reconcile(local, server, crate::sync::scheduler::DEFAULT_TOMBSTONE_THRESHOLD_PCT) { + crate::sync::budget::RequestBudget::DELTA_MISMATCH_CAP + } else { + 0 + } +} + +fn now_unix_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncIdleAck { + server_id: String, + library_scope: String, + kind: String, + ok: bool, + error: Option, +} + +impl SyncIdleAck { + fn ok(server_id: &str, scope: &str, kind: &str) -> Self { + Self { + server_id: server_id.to_string(), + library_scope: scope.to_string(), + kind: kind.to_string(), + ok: true, + error: None, + } + } + fn err(server_id: &str, scope: &str, kind: &str, message: &str) -> Self { + Self { + server_id: server_id.to_string(), + library_scope: scope.to_string(), + kind: kind.to_string(), + ok: false, + error: Some(message.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::TrackRow; + use crate::store::LibraryStore; + use std::sync::Arc; + + fn make_row(server: &str, id: &str, album_id: &str, track_no: i64) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: format!("Track {id}"), + title_sort: None, + artist: Some("A".into()), + artist_id: Some("ar1".into()), + album: "Album".into(), + album_id: Some(album_id.into()), + album_artist: Some("A".into()), + duration_sec: 240, + track_number: Some(track_no), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: Some(format!("/path/{id}.flac")), + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: Some(format!("hash-{id}")), + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + // The command functions take `tauri::State` which we can't easily + // construct in unit tests without a Tauri runtime. The tests below + // exercise the *underlying* logic by calling the equivalent + // `LibraryRuntime` + repo paths directly. Integration coverage with + // a real Tauri app lives outside this crate (PR-5c devtools test). + + fn runtime(store: Arc) -> LibraryRuntime { + LibraryRuntime::new(store) + } + + #[test] + fn get_status_returns_defaults_when_no_row_exists() { + let store = Arc::new(LibraryStore::open_in_memory()); + let rt = runtime(store); + // Simulate command body — same logic as `library_get_status`. + let local_max = local_tracks_max_updated_ms(&rt.store, "s1").unwrap(); + assert!(local_max.is_none()); + } + + #[test] + fn library_track_dto_from_row_preserves_hot_columns() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[make_row("s1", "tr_1", "al_1", 5)]) + .unwrap(); + let found = TrackRepository::new(&store).find_one("s1", "tr_1").unwrap().unwrap(); + let dto = LibraryTrackDto::from_row(&found); + assert_eq!(dto.id, "tr_1"); + assert_eq!(dto.album_id.as_deref(), Some("al_1")); + assert_eq!(dto.track_number, Some(5)); + } + + #[test] + fn patch_content_hash_sets_value_and_noops_on_absent_or_empty() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[make_row("s1", "tr_1", "al_1", 1)]) + .unwrap(); + let rt = runtime(store.clone()); + + let read = |store: &LibraryStore| -> Option { + store + .with_conn("misc", |c| { + c.query_row( + "SELECT content_hash FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| r.get(0), + ) + }) + .unwrap() + }; + + // No-ops leave the existing value untouched: empty md5, and a row that + // doesn't exist (the absent-row case is how "index off" stays a no-op). + patch_content_hash(&rt, "s1", "tr_1", "").unwrap(); + patch_content_hash(&rt, "s1", "missing", "deadbeef").unwrap(); + assert_eq!(read(&store).as_deref(), Some("hash-tr_1")); + + patch_content_hash(&rt, "s1", "tr_1", "md5-playback").unwrap(); + assert_eq!(read(&store).as_deref(), Some("md5-playback")); + } + + #[test] + fn apply_track_patch_sets_clears_and_leaves_fields() { + // §6.5 patch-on-use: present value sets, explicit null clears, absent key + // leaves the column untouched — so `unstar` ({starredAt:null}) actually + // un-stars the local row. + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[make_row("s1", "tr_1", "al_1", 1)]) + .unwrap(); + let rt = runtime(store.clone()); + let read = |store: &LibraryStore| -> (Option, Option) { + store + .with_conn("misc", |c| { + c.query_row( + "SELECT starred_at, user_rating FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap() + }; + + apply_track_patch(&rt, "s1", "tr_1", &serde_json::json!({ "starredAt": 1700, "userRating": 4 })) + .unwrap(); + assert_eq!(read(&store), (Some(1700), Some(4))); + + // Explicit null clears starred_at; absent userRating stays. + apply_track_patch(&rt, "s1", "tr_1", &serde_json::json!({ "starredAt": null })).unwrap(); + assert_eq!(read(&store), (None, Some(4)), "null clears, absent key untouched"); + + // Empty patch is a no-op. + apply_track_patch(&rt, "s1", "tr_1", &serde_json::json!({})).unwrap(); + assert_eq!(read(&store), (None, Some(4))); + } + + #[test] + fn find_by_album_orders_by_disc_then_track_then_id() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[ + make_row("s1", "tr_b", "al_1", 2), + make_row("s1", "tr_a", "al_1", 1), + make_row("s1", "tr_c", "al_2", 1), + ]) + .unwrap(); + let album1 = TrackRepository::new(&store).find_by_album("s1", "al_1").unwrap(); + let ids: Vec<&str> = album1.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["tr_a", "tr_b"]); + } + + #[test] + fn find_batch_preserves_input_order_and_drops_unknowns() { + let store = Arc::new(LibraryStore::open_in_memory()); + TrackRepository::new(&store) + .upsert_batch(&[ + make_row("s1", "tr_1", "al_1", 1), + make_row("s1", "tr_2", "al_1", 2), + ]) + .unwrap(); + let pairs = vec![ + ("s1".to_string(), "tr_2".to_string()), + ("s1".to_string(), "tr_missing".to_string()), + ("s1".to_string(), "tr_1".to_string()), + ]; + let rows = TrackRepository::new(&store).find_batch(&pairs).unwrap(); + let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["tr_2", "tr_1"]); + } + + #[test] + fn batch_limit_constant_matches_spec_cap() { + assert_eq!(TRACKS_BATCH_LIMIT, 100); + } + + #[test] + fn normalize_base_url_adds_scheme_and_strips_trailing_slash() { + assert_eq!(normalize_base_url("nas.example.com"), "http://nas.example.com"); + assert_eq!(normalize_base_url("nas.example.com/"), "http://nas.example.com"); + assert_eq!(normalize_base_url("192.168.1.5:4533"), "http://192.168.1.5:4533"); + } + + #[test] + fn normalize_base_url_preserves_existing_scheme() { + assert_eq!(normalize_base_url("https://nas.example.com"), "https://nas.example.com"); + assert_eq!(normalize_base_url("https://nas.example.com/"), "https://nas.example.com"); + assert_eq!(normalize_base_url("http://localhost:4533/"), "http://localhost:4533"); + } + + #[test] + fn normalize_base_url_trims_whitespace() { + assert_eq!(normalize_base_url(" nas.example.com "), "http://nas.example.com"); + } + + #[test] + fn sync_outcome_treats_cancellation_as_silent_success() { + // Cancellation (user cancel, or a newer sync_start superseding this + // job) must not surface as a failure on the sync-idle event. + assert!(sync_outcome_to_result::<()>(Ok(())).is_ok()); + assert!(sync_outcome_to_result::<()>(Err(SyncError::Cancelled)).is_ok()); + let err = sync_outcome_to_result::<()>(Err(SyncError::Transport("boom".into()))); + assert_eq!(err, Err("sync transport: boom".to_string())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn navidrome_token_with_retry_returns_token_on_success() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "token": "nd-tok", "userId": "u1" + }))) + .mount(&server) + .await; + let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await; + assert_eq!(tok.as_deref(), Some("nd-tok")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn navidrome_token_with_retry_returns_none_after_exhausting_attempts() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + // No `token` field → navidrome_token errors on every attempt; after + // the retries are exhausted the helper yields None (caller then falls + // back to a cached bearer / Subsonic-only). + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&server) + .await; + let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await; + assert!(tok.is_none()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/cross_server.rs b/src-tauri/crates/psysonic-library/src/cross_server.rs new file mode 100644 index 00000000..ad930073 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/cross_server.rs @@ -0,0 +1,376 @@ +//! Cross-server search (spec §5.5B / §5.9). Primary FTS union (`hits`, +//! bm25-ordered, deduped by canonical id) plus the H3 fuzzy fallback +//! (`fuzzy`): per-server `title LIKE` matches the exact FTS pass missed. +//! UI wiring stays PR-7. + +use std::collections::HashSet; + +use rusqlite::types::Value as SqlValue; + +use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto}; +use crate::repos; +use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX}; +use crate::store::LibraryStore; + +/// §5.9 caps the fuzzy fallback at "top 20 per server" so a `LIKE %…%` scan +/// (no index) stays bounded. +const FUZZY_PER_SERVER_CAP: usize = 20; + +/// `library_search_cross_server` (§5.5B / §5.9 A′). Primary FTS union over +/// the requested servers (or all `ready` servers), bm25-ordered, deduped by +/// canonical id where a `track_canonical_link` row exists. +pub fn run_cross_server_search( + store: &LibraryStore, + query: &str, + limit: u32, + servers: Option<&[String]>, +) -> Result { + let limit = limit.clamp(1, PAGE_LIMIT_MAX); + let Some(fts) = fts_query(query) else { + return Ok(LibraryCrossServerSearchResponse::default()); + }; + + // Explicit `servers` is an override (caller's choice); otherwise default + // to every server whose index is `ready` (§5.9). + let targets: Vec = match servers { + Some(list) if !list.is_empty() => list.to_vec(), + _ => ready_servers(store)?, + }; + if targets.is_empty() { + return Ok(LibraryCrossServerSearchResponse::default()); + } + + let placeholders = vec!["?"; targets.len()].join(", "); + let cols = aliased_track_columns("t"); + let sql = format!( + "SELECT {cols}, l.canonical_id \ + FROM track_fts f \ + JOIN track t ON t.rowid = f.rowid \ + LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \ + WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({placeholders}) \ + ORDER BY bm25(track_fts) LIMIT ?" + ); + + let mut params: Vec = Vec::with_capacity(targets.len() + 2); + params.push(SqlValue::Text(fts)); + for s in &targets { + params.push(SqlValue::Text(s.clone())); + } + params.push(SqlValue::Integer(limit as i64)); + + let canonical_idx = repos::track_columns().split(',').count(); + let rows: Vec<(LibraryTrackDto, Option)> = store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + // Bind the collected `Result` before unwrapping so the `MappedRows` + // borrow of `stmt` ends inside the block (rusqlite borrow quirk). + let collected: rusqlite::Result)>> = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?; + let canonical: Option = r.get(canonical_idx)?; + Ok((track, canonical)) + })? + .collect(); + collected + })?; + + // Dedup the exact hits by canonical id (§5.5B step 2). Rows with no + // canonical link are always kept — the link table is sparse for tracks + // lacking ISRC/MBID. + let mut seen: HashSet = HashSet::new(); + let mut hits: Vec = Vec::with_capacity(rows.len()); + for (track, canonical) in rows { + if let Some(cid) = canonical { + if !seen.insert(cid) { + continue; + } + } + hits.push(track); + } + + // H3 fuzzy fallback (§5.9): catch what the exact FTS pass missed. + let hit_keys: HashSet<(String, String)> = hits + .iter() + .map(|t| (t.server_id.clone(), t.id.clone())) + .collect(); + let fuzzy = fuzzy_matches(store, &targets, query.trim(), &mut seen, &hit_keys, limit as usize)?; + + Ok(LibraryCrossServerSearchResponse { + hits, + fuzzy, + servers_searched: targets, + }) +} + +/// §5.9 fuzzy fallback: per target server, `title LIKE %query%` for matches +/// the exact FTS pass missed (diacritics, partial words). Skips rows already +/// in `hit_keys` and dedupes by canonical id against `seen` (which holds the +/// exact hits' canonical ids). Capped per server (`FUZZY_PER_SERVER_CAP`) and +/// overall (`overall_cap`). +fn fuzzy_matches( + store: &LibraryStore, + targets: &[String], + query: &str, + seen: &mut HashSet, + hit_keys: &HashSet<(String, String)>, + overall_cap: usize, +) -> Result, String> { + let like = like_contains(query); + let cols = aliased_track_columns("t"); + let canonical_idx = repos::track_columns().split(',').count(); + let sql = format!( + "SELECT {cols}, l.canonical_id \ + FROM track t \ + LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \ + WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \ + ORDER BY t.title COLLATE NOCASE ASC LIMIT ?" + ); + + let mut out: Vec = Vec::new(); + for server in targets { + if out.len() >= overall_cap { + break; + } + let bound = [ + SqlValue::Text(server.clone()), + SqlValue::Text(like.clone()), + SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64), + ]; + let rows: Vec<(LibraryTrackDto, Option)> = store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let collected: rusqlite::Result)>> = stmt + .query_map(rusqlite::params_from_iter(bound.iter()), |r| { + let track = + repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?; + let canonical: Option = r.get(canonical_idx)?; + Ok((track, canonical)) + })? + .collect(); + collected + })?; + + for (track, canonical) in rows { + if out.len() >= overall_cap { + break; + } + if hit_keys.contains(&(track.server_id.clone(), track.id.clone())) { + continue; // already an exact hit + } + if let Some(cid) = canonical { + if !seen.insert(cid) { + continue; // canonical already represented (hit or earlier fuzzy) + } + } + out.push(track); + } + } + Ok(out) +} + +fn ready_servers(store: &LibraryStore) -> Result, String> { + store.with_read_conn(|conn| { + let mut stmt = + conn.prepare("SELECT DISTINCT server_id FROM sync_state WHERE sync_phase = 'ready'")?; + let collected: rusqlite::Result> = + stmt.query_map([], |r| r.get(0))?.collect(); + collected + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + + fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(format!("ar_{artist}")), + album: album.into(), + album_id: Some(format!("al_{album}")), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + fn set_phase(store: &LibraryStore, server: &str, phase: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO sync_state (server_id, library_scope, sync_phase) \ + VALUES (?1, '', ?2) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET sync_phase = excluded.sync_phase", + rusqlite::params![server, phase], + ) + }) + .unwrap(); + } + + #[test] + fn union_searches_ready_servers_only() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Alb"), + track("s2", "t2", "Aurora", "Beth", "Alb"), + track("s3", "t3", "Aurora", "Cara", "Alb"), + ]) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + set_phase(&store, "s3", "idle"); // not ready → excluded + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + let servers: HashSet<&str> = resp.hits.iter().map(|t| t.server_id.as_str()).collect(); + assert_eq!(servers, HashSet::from(["s1", "s2"])); + assert_eq!(resp.servers_searched.len(), 2); + } + + #[test] + fn explicit_servers_override_ready_gate() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s9", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + // s9 is not marked ready, but an explicit servers list overrides. + let resp = run_cross_server_search(&store, "aurora", 50, Some(&["s9".to_string()])).unwrap(); + assert_eq!(resp.hits.len(), 1); + assert_eq!(resp.servers_searched, vec!["s9".to_string()]); + } + + #[test] + fn dedups_by_canonical_id() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Alb"), + track("s2", "t2", "Aurora", "Anna", "Alb"), + ]) + .unwrap(); + // Both tracks link to the same canonical id → one survives. + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO canonical_track (id, created_at, updated_at) VALUES ('can1', 1, 1)", + [], + )?; + for (s, t) in [("s1", "t1"), ("s2", "t2")] { + c.execute( + "INSERT INTO track_canonical_link \ + (server_id, track_id, canonical_id, match_method, confidence, linked_at) \ + VALUES (?1, ?2, 'can1', 'isrc', 1.0, 1)", + rusqlite::params![s, t], + )?; + } + Ok(()) + }) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 1, "duplicate canonical id collapses to one hit"); + } + + #[test] + fn unlinked_rows_are_never_deduped() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora", "Anna", "Alb"), + track("s2", "t2", "Aurora", "Beth", "Alb"), + ]) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + // No canonical links → both kept even though titles match. + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 2); + } + + #[test] + fn empty_query_returns_empty() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + set_phase(&store, "s1", "ready"); + let resp = run_cross_server_search(&store, " ", 50, None).unwrap(); + assert!(resp.hits.is_empty()); + assert!(resp.servers_searched.is_empty()); + } + + #[test] + fn no_ready_servers_returns_empty() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + // No sync_state row marked ready, and no explicit servers given. + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert!(resp.hits.is_empty()); + assert!(resp.servers_searched.is_empty()); + } + + // ── H3: fuzzy fallback (§5.9) ────────────────────────────────────── + + #[test] + fn fuzzy_catches_titles_the_exact_pass_missed() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + // exact FTS term hit + track("s1", "t1", "Aurora", "Anna", "Alb"), + // FTS tokenizes to "auroras"/"borealis" → misses term "aurora"; + // LIKE %aurora% still catches it. + track("s2", "t2", "Auroras Borealis", "Beth", "Alb"), + ]) + .unwrap(); + set_phase(&store, "s1", "ready"); + set_phase(&store, "s2", "ready"); + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 1, "exact FTS hit"); + assert_eq!(resp.hits[0].id, "t1"); + assert_eq!(resp.fuzzy.len(), 1, "fuzzy catches the FTS miss"); + assert_eq!(resp.fuzzy[0].id, "t2"); + } + + #[test] + fn fuzzy_excludes_exact_hits() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")]) + .unwrap(); + set_phase(&store, "s1", "ready"); + // "Aurora" is both an FTS hit and a LIKE match — must appear only once. + let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap(); + assert_eq!(resp.hits.len(), 1); + assert!(resp.fuzzy.is_empty(), "exact hits are not repeated in fuzzy"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs new file mode 100644 index 00000000..377672bc --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -0,0 +1,690 @@ +//! Public DTOs the Tauri command surface returns. camelCase wire shape +//! per `src-tauri/CLAUDE.md`. PR-5a only defines what the read-only +//! commands need; PR-5b adds sync-progress / cancel-ack shapes. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::filter::{EntityKind, FilterOp}; +use crate::repos::TrackRow; +use crate::store::LibraryStore; + +/// `library_get_status` payload — mirrors the `sync_state` row plus a +/// few derived counters from `track`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SyncStateDto { + pub server_id: String, + pub library_scope: String, + #[serde(default)] + pub sync_phase: String, + #[serde(default)] + pub capability_flags: u32, + #[serde(default)] + pub library_tier: String, + pub last_full_sync_at: Option, + pub last_delta_sync_at: Option, + pub next_poll_at: Option, + pub server_last_scan_iso: Option, + pub indexes_last_modified_ms: Option, + pub artists_last_modified_ms: Option, + pub local_track_count: Option, + pub server_track_count: Option, + pub last_error: Option, + /// `MAX(server_updated_at)` over local non-deleted tracks — the + /// implicit "tracks watermark" the N1-delta uses. + pub local_tracks_max_updated_ms: Option, + /// Cheap `EXISTS` over `track` — avoids a full `COUNT(*)` on every status read. + #[serde(default)] + pub has_local_tracks: bool, + /// Active/resumed initial-sync ingest strategy (`n1` / `s1` / `s2`), if any. + #[serde(default)] + pub ingest_strategy: Option, + /// Cursor phase during initial sync (`ingest`, `artist_pass`, …). + #[serde(default)] + pub ingest_phase: Option, + /// Tracks ingested so far per persisted cursor (informational during IS-3). + #[serde(default)] + pub cursor_ingested_count: Option, + /// Server flagged after N1 deep-offset failure — prefers S1/S2 on next run. + #[serde(default)] + pub n1_bulk_unreliable: Option, +} + +/// E3 readiness summary attached to a single-track `library_get_track` read. +/// Per-server, read-only, best-effort — never blocks on the network. Omitted +/// from list/batch reads (would be one analysis probe per row). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct TrackEnrichmentDto { + pub waveform_ready: bool, + pub loudness_ready: bool, + pub lyrics_cached: bool, +} + +/// `library_get_track` / `library_search` row shape — flat projection +/// over `track`'s hot columns plus the raw JSON sub-tree. Frontend +/// re-assembles its own `LibraryTrack` shape from this. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTrackDto { + // Ref + pub server_id: String, + pub id: String, + pub content_hash: Option, + + // Hot columns + pub title: String, + pub title_sort: Option, + pub artist: Option, + pub artist_id: Option, + pub album: String, + pub album_id: Option, + pub album_artist: Option, + pub duration_sec: i64, + pub track_number: Option, + pub disc_number: Option, + pub year: Option, + pub genre: Option, + pub suffix: Option, + pub bit_rate: Option, + pub size_bytes: Option, + pub cover_art_id: Option, + pub starred_at: Option, + pub user_rating: Option, + pub play_count: Option, + pub played_at: Option, + pub server_path: Option, + pub library_id: Option, + pub isrc: Option, + pub mbid_recording: Option, + pub bpm: Option, + pub replay_gain_track_db: Option, + pub replay_gain_album_db: Option, + + pub server_updated_at: Option, + pub server_created_at: Option, + pub synced_at: i64, + + /// E3 readiness summary. Only populated by `library_get_track`; `None` + /// (omitted on the wire) for list/batch projections via `from_row`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enrichment: Option, + + /// Original Subsonic / Navidrome song JSON the sync engine stored. + /// Frontend parses this lazily when it needs OpenSubsonic extras + /// (contributors, replayGain detail, …). + pub raw_json: Value, +} + +impl LibraryTrackDto { + pub fn from_row(row: &TrackRow) -> Self { + let raw_json: Value = serde_json::from_str(&row.raw_json).unwrap_or(Value::Null); + Self { + server_id: row.server_id.clone(), + id: row.id.clone(), + content_hash: row.content_hash.clone(), + title: row.title.clone(), + title_sort: row.title_sort.clone(), + artist: row.artist.clone(), + artist_id: row.artist_id.clone(), + album: row.album.clone(), + album_id: row.album_id.clone(), + album_artist: row.album_artist.clone(), + duration_sec: row.duration_sec, + track_number: row.track_number, + disc_number: row.disc_number, + year: row.year, + genre: row.genre.clone(), + suffix: row.suffix.clone(), + bit_rate: row.bit_rate, + size_bytes: row.size_bytes, + cover_art_id: row.cover_art_id.clone(), + starred_at: row.starred_at, + user_rating: row.user_rating, + play_count: row.play_count, + played_at: row.played_at, + server_path: row.server_path.clone(), + library_id: row.library_id.clone(), + isrc: row.isrc.clone(), + mbid_recording: row.mbid_recording.clone(), + bpm: row.bpm, + replay_gain_track_db: row.replay_gain_track_db, + replay_gain_album_db: row.replay_gain_album_db, + server_updated_at: row.server_updated_at, + server_created_at: row.server_created_at, + synced_at: row.synced_at, + enrichment: None, + raw_json, + } + } +} + +/// `library_get_tracks_batch` / `library_search` envelope. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTracksEnvelope { + pub tracks: Vec, + pub total: u32, +} + +/// `library_get_artifact` payload — one row of `track_artifact`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TrackArtifactDto { + pub server_id: String, + pub track_id: String, + pub artifact_kind: String, + pub format: String, + pub source_kind: String, + pub source_id: String, + pub language: Option, + pub content_text: Option, + pub content_bytes: i64, + pub not_found: bool, + pub content_hash: Option, + pub fetched_at: i64, + pub expires_at: Option, +} + +/// `library_get_facts` row. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TrackFactDto { + pub server_id: String, + pub track_id: String, + pub fact_kind: String, + pub value_real: Option, + pub value_int: Option, + pub value_text: Option, + pub unit: Option, + pub source_kind: String, + pub source_id: String, + pub confidence: f64, + pub content_hash: Option, + pub fetched_at: i64, + pub expires_at: Option, +} + +/// `library_get_offline_path` outcome — either a path string or a +/// `missing` flag so the frontend can show a hint without polling. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OfflinePathDto { + pub server_id: String, + pub track_id: String, + pub local_path: Option, + pub missing: bool, +} + +/// Compact track reference used as input by `library_get_tracks_batch`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct TrackRefDto { + pub server_id: String, + pub track_id: String, + #[serde(default)] + pub content_hash: Option, +} + +/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto` +/// minus the server-supplied `server_id` / `track_id` (provided as +/// command args) and `fetched_at` (stamped server-side from `now`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactInputDto { + pub artifact_kind: String, + pub format: String, + pub source_kind: String, + pub source_id: String, + #[serde(default)] + pub language: Option, + #[serde(default)] + pub content_text: Option, + #[serde(default)] + pub content_blob: Option>, + #[serde(default)] + pub content_bytes: i64, + #[serde(default)] + pub not_found: bool, + #[serde(default)] + pub content_hash: Option, + #[serde(default)] + pub expires_at: Option, +} + +/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the +/// indices. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FactInputDto { + pub fact_kind: String, + #[serde(default)] + pub value_real: Option, + #[serde(default)] + pub value_int: Option, + #[serde(default)] + pub value_text: Option, + #[serde(default)] + pub unit: Option, + pub source_kind: String, + pub source_id: String, + #[serde(default = "default_confidence")] + pub confidence: f64, + #[serde(default)] + pub content_hash: Option, + #[serde(default)] + pub expires_at: Option, +} + +fn default_confidence() -> f64 { + 1.0 +} + +/// `library_purge_server` outcome. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PurgeReportDto { + pub tracks_deleted: u32, + pub albums_deleted: u32, + pub artists_deleted: u32, + pub offline_rows_deleted: u32, + /// Total bytes freed across the purged scopes (best-effort). + pub bytes_freed: i64, +} + +/// `library_sync_start` ack. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncJobDto { + pub job_id: String, + pub server_id: String, + /// `"initial_sync"` or `"delta_sync"`. + pub kind: String, +} + +// ────────────────────────────────────────────────────────────────────── +// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B) +// ────────────────────────────────────────────────────────────────────── + +/// `library_advanced_search` row shape for an album. Flat projection over +/// the `album` hot columns plus the raw JSON sub-tree (mirrors +/// `LibraryTrackDto`'s lazy-parse contract). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAlbumDto { + pub server_id: String, + pub id: String, + pub name: String, + pub artist: Option, + pub artist_id: Option, + pub song_count: Option, + pub duration_sec: Option, + pub year: Option, + pub genre: Option, + pub cover_art_id: Option, + pub starred_at: Option, + pub synced_at: i64, + pub raw_json: Value, +} + +/// `library_advanced_search` row shape for an artist. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryArtistDto { + pub server_id: String, + pub id: String, + pub name: String, + pub album_count: Option, + pub synced_at: i64, + pub raw_json: Value, +} + +/// One filter predicate. `field` is a `FilterFieldRegistry` id (§5.13.3), +/// `op` the comparison, `value` / `valueTo` the operands. `between` uses +/// both bounds (inclusive); scalar ops use `value` only; `isTrue` ignores +/// the value side. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryFilterClause { + pub field: String, + pub op: FilterOp, + #[serde(default)] + pub value: Option, + #[serde(default)] + pub value_to: Option, +} + +/// One sort key. `field` is a registry id; `dir` is `asc` / `desc`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibrarySortClause { + pub field: String, + pub dir: SortDir, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SortDir { + Asc, + Desc, +} + +/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an +/// `fts` clause on the text fields; `entityTypes` controls which of the +/// three queries run; `filters` are combined with AND. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAdvancedSearchRequest { + pub server_id: String, + #[serde(default)] + pub library_scope: Option, + #[serde(default)] + pub query: Option, + pub entity_types: Vec, + #[serde(default)] + pub filters: Vec, + #[serde(default)] + pub starred_only: Option, + #[serde(default)] + pub sort: Vec, + pub limit: u32, + #[serde(default)] + pub offset: u32, + /// When true, skip per-entity COUNT queries (Live Search / small pages). + #[serde(default)] + pub skip_totals: bool, +} + +/// Per-entity result counts (full match count, not page size). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LibrarySearchTotals { + pub artists: u32, + pub albums: u32, + pub tracks: u32, +} + +/// `library_advanced_search` response (§5.13.2). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAdvancedSearchResponse { + pub artists: Vec, + pub albums: Vec, + pub tracks: Vec, + pub totals: LibrarySearchTotals, + /// Distinct registry field ids that were actually applied — UI chips / + /// debug. Includes `starred` when `starredOnly` is set. + pub applied_filters: Vec, + /// Always `"local"` from this command (it queries the local index); the + /// frontend's fallback decides local vs network (§5.13.6). + pub source: String, +} + +/// `library_live_search` response — lean FTS dropdown (§5.9 / P24). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryLiveSearchRequest { + pub server_id: String, + pub query: String, + #[serde(default)] + pub library_scope: Option, + #[serde(default)] + pub artist_limit: Option, + #[serde(default)] + pub album_limit: Option, + #[serde(default)] + pub song_limit: Option, + #[serde(default)] + pub request_epoch: Option, +} + +/// `library_live_search` response — lean FTS dropdown (§5.9 / P24). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryLiveSearchResponse { + pub artists: Vec, + pub albums: Vec, + pub tracks: Vec, + pub source: String, +} + +/// `library_search_cross_server` response (§5.5B / §5.9). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryCrossServerSearchResponse { + /// Primary FTS-union hits, deduped by canonical id where a link exists. + pub hits: Vec, + /// Fuzzy fallback (§5.9 / H3): per-server `title LIKE` matches that the + /// exact FTS pass missed (diacritics, partial words). Excludes anything + /// already in `hits` and dedupes by canonical id against them. + pub fuzzy: Vec, + /// The server ids that were actually searched (resolved from the + /// request's `servers` or all `ready` servers). + pub servers_searched: Vec, +} + +/// Read `MAX(server_updated_at)` for non-deleted tracks on this server +/// — used by `SyncStateDto` so callers can show "tracks watermark" in +/// Settings without a separate column. +pub fn local_tracks_max_updated_ms( + store: &LibraryStore, + server_id: &str, +) -> Result, String> { + store + .with_read_conn(|c| { + c.query_row( + "SELECT MAX(server_updated_at) FROM track \ + WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![server_id], + |row| row.get::<_, Option>(0), + ) + }) + .map_err(|e| e.to_string()) +} + +/// Cheap `EXISTS` — true when at least one non-deleted track is indexed. +pub fn track_index_nonempty(store: &LibraryStore, server_id: &str) -> Result { + store + .with_read_conn(|c| { + c.query_row( + "SELECT EXISTS(SELECT 1 FROM track WHERE server_id = ?1 AND deleted = 0 LIMIT 1)", + rusqlite::params![server_id], + |row| row.get(0), + ) + }) + .map_err(|e| e.to_string()) +} + +/// Live non-deleted track count for a server (used when the sync_state +/// snapshot is missing or stale). +pub fn count_local_tracks(store: &LibraryStore, server_id: &str) -> Result { + store + .with_read_conn(|c| { + c.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![server_id], + |row| row.get(0), + ) + }) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::TrackRepository; + + fn sample_row() -> TrackRow { + TrackRow { + server_id: "s1".into(), + id: "tr_1".into(), + title: "Hello".into(), + title_sort: None, + artist: Some("World".into()), + artist_id: Some("ar_1".into()), + album: "An Album".into(), + album_id: Some("al_1".into()), + album_artist: Some("World".into()), + duration_sec: 240, + track_number: Some(3), + disc_number: Some(1), + year: Some(2024), + genre: Some("Ambient".into()), + suffix: Some("flac".into()), + bit_rate: Some(1000), + size_bytes: Some(32_000_000), + cover_art_id: Some("cv_1".into()), + starred_at: None, + user_rating: None, + play_count: Some(0), + played_at: None, + server_path: Some("/path/x.flac".into()), + library_id: Some("1".into()), + isrc: Some("USRC17607839".into()), + mbid_recording: Some("mb-1".into()), + bpm: Some(120), + replay_gain_track_db: Some(-1.2), + replay_gain_album_db: Some(-0.8), + content_hash: Some("deadbeef".into()), + server_updated_at: Some(1_700_000_000), + server_created_at: Some(1_699_000_000), + deleted: false, + synced_at: 1_700_000_500, + raw_json: r#"{"replayGain":{"trackGain":-1.2}}"#.into(), + } + } + + #[test] + fn library_track_dto_serializes_field_names_camel_case() { + let dto = LibraryTrackDto::from_row(&sample_row()); + let json = serde_json::to_value(&dto).unwrap(); + // Spot-check critical wire keys — IPC contract. + for key in [ + "serverId", + "contentHash", + "albumArtist", + "durationSec", + "trackNumber", + "discNumber", + "coverArtId", + "userRating", + "playCount", + "playedAt", + "serverPath", + "libraryId", + "mbidRecording", + "replayGainTrackDb", + "replayGainAlbumDb", + "serverUpdatedAt", + "syncedAt", + "rawJson", + ] { + assert!( + json.get(key).is_some(), + "expected camelCase key `{key}` in serialized DTO, got {json}" + ); + } + } + + #[test] + fn library_track_dto_parses_raw_json_into_value() { + let dto = LibraryTrackDto::from_row(&sample_row()); + let rg = dto + .raw_json + .get("replayGain") + .and_then(|v| v.get("trackGain")) + .and_then(|v| v.as_f64()) + .unwrap(); + assert!((rg - -1.2).abs() < 0.001); + } + + #[test] + fn library_track_dto_falls_back_to_null_on_invalid_raw_json() { + let mut row = sample_row(); + row.raw_json = "{not valid json}".into(); + let dto = LibraryTrackDto::from_row(&row); + assert!(dto.raw_json.is_null(), "invalid JSON must surface as Value::Null"); + } + + #[test] + fn local_tracks_max_updated_returns_max_over_non_deleted_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let mut r1 = sample_row(); + r1.server_updated_at = Some(1_000); + let mut r2 = sample_row(); + r2.id = "tr_2".into(); + r2.server_updated_at = Some(3_000); + let mut r3 = sample_row(); + r3.id = "tr_3".into(); + r3.server_updated_at = Some(5_000); + r3.deleted = true; + repo.upsert_batch(&[r1, r2, r3]).unwrap(); + + assert_eq!( + local_tracks_max_updated_ms(&store, "s1").unwrap(), + Some(3_000), + "deleted rows must be excluded" + ); + } + + #[test] + fn count_local_tracks_matches_non_deleted_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let mut deleted = sample_row(); + deleted.id = "tr_del".into(); + deleted.deleted = true; + repo.upsert_batch(&[sample_row(), deleted]).unwrap(); + assert_eq!(count_local_tracks(&store, "s1").unwrap(), 1); + } + + #[test] + fn track_ref_dto_roundtrips_through_json() { + let r = TrackRefDto { + server_id: "s1".into(), + track_id: "tr_1".into(), + content_hash: Some("h".into()), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json.get("serverId").and_then(|v| v.as_str()), Some("s1")); + let back: TrackRefDto = serde_json::from_value(json).unwrap(); + assert_eq!(back, r); + } + + #[test] + fn sync_state_dto_omits_null_optionals_cleanly() { + let dto = SyncStateDto { + server_id: "s1".into(), + library_scope: "".into(), + sync_phase: "idle".into(), + capability_flags: 0, + library_tier: "unknown".into(), + last_full_sync_at: None, + last_delta_sync_at: None, + next_poll_at: None, + server_last_scan_iso: None, + indexes_last_modified_ms: None, + artists_last_modified_ms: None, + local_track_count: None, + server_track_count: None, + last_error: None, + local_tracks_max_updated_ms: None, + has_local_tracks: false, + ingest_strategy: None, + ingest_phase: None, + cursor_ingested_count: None, + n1_bulk_unreliable: None, + }; + let json = serde_json::to_value(dto).unwrap(); + assert_eq!( + json.get("syncPhase").and_then(|v| v.as_str()), + Some("idle") + ); + // `null` survives as JSON null, not omitted — explicit shape + // for the WebView so it can distinguish "missing" from + // "unset". + assert!(json.get("lastFullSyncAt").unwrap().is_null()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/filter.rs b/src-tauri/crates/psysonic-library/src/filter.rs new file mode 100644 index 00000000..8254c4ca --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/filter.rs @@ -0,0 +1,415 @@ +//! `FilterFieldRegistry` — Rust source of truth for Advanced Search filter +//! fields (spec §5.13.3 / P38). The full SQL builder (`AdvancedSearchQuery`, +//! §5.13.5) and the Tauri command surface (§5.13.6) come later; PR-1a +//! ships the registry shape + the v1 fields + the entity-routing rule. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntityKind { + Track, + Album, + Artist, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilterOp { + /// FTS5 MATCH (`track_fts`). Only valid on the `text` field in v1. + Fts, + Eq, + /// Membership test against a list of values. (Not yet in v1; reserved.) + In, + Gte, + Lte, + Between, + /// Boolean field — value side is ignored, presence = `IS NOT NULL`. + IsTrue, +} + +impl FilterOp { + /// Wire spelling — matches the TypeScript `FilterOperator` union and the + /// `FilterOp::from_wire` parser. Used in error messages too. + pub fn as_str(self) -> &'static str { + match self { + FilterOp::Fts => "fts", + FilterOp::Eq => "eq", + FilterOp::In => "in", + FilterOp::Gte => "gte", + FilterOp::Lte => "lte", + FilterOp::Between => "between", + FilterOp::IsTrue => "is_true", + } + } + + /// Parse the wire operator. Spec §5.13.2 lists a few operators + /// (`neq` / `contains` / `is_false`) the v1 builder doesn't implement + /// yet — those return `None` so the caller can raise a clear error + /// rather than silently dropping the clause. + pub fn from_wire(wire: &str) -> Option { + match wire { + "fts" => Some(FilterOp::Fts), + "eq" => Some(FilterOp::Eq), + "in" => Some(FilterOp::In), + "gte" => Some(FilterOp::Gte), + "lte" => Some(FilterOp::Lte), + "between" => Some(FilterOp::Between), + "is_true" => Some(FilterOp::IsTrue), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FilterStatus { + /// v1: built into the SQL builder and exercised by parity tests. + V1, + /// Schema + SQL builder present (the request and registry accept it), + /// but hidden from the v1 UI — see §5.13.4 (`bpm` dual-storage). + SchemaV1UiLater, + /// Reserved column / planned-but-not-built. + Planned, + /// Out of scope for v1 entirely. + Future, +} + +#[derive(Debug, Clone, Copy)] +pub struct FilterField { + pub id: &'static str, + pub entities: &'static [EntityKind], + pub ops: &'static [FilterOp], + pub status: FilterStatus, +} + +/// Static v1 registry. Adding a row here is the only thing required to expose +/// a new filter field (plus, when the storage isn't yet a hot column / index, +/// a separate `00X_*.sql` migration — see §5.7). No new invoke is needed. +pub const FILTER_FIELD_REGISTRY: &[FilterField] = &[ + FilterField { + id: "text", + entities: &[EntityKind::Track, EntityKind::Album, EntityKind::Artist], + ops: &[FilterOp::Fts], + status: FilterStatus::V1, + }, + FilterField { + id: "genre", + entities: &[EntityKind::Track, EntityKind::Album], + ops: &[FilterOp::Eq], + status: FilterStatus::V1, + }, + FilterField { + id: "year", + entities: &[EntityKind::Track, EntityKind::Album], + ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between], + status: FilterStatus::V1, + }, + FilterField { + id: "starred", + entities: &[EntityKind::Track, EntityKind::Album, EntityKind::Artist], + ops: &[FilterOp::IsTrue], + status: FilterStatus::V1, + }, + FilterField { + id: "user_rating", + entities: &[EntityKind::Track], + ops: &[FilterOp::Gte, FilterOp::Eq], + status: FilterStatus::Planned, + }, + FilterField { + id: "suffix", + entities: &[EntityKind::Track], + ops: &[FilterOp::Eq, FilterOp::In], + status: FilterStatus::Planned, + }, + FilterField { + id: "bit_rate", + entities: &[EntityKind::Track], + ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between], + status: FilterStatus::Planned, + }, + FilterField { + id: "bpm", + entities: &[EntityKind::Track], + ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between], + status: FilterStatus::SchemaV1UiLater, + }, +]; + +pub fn lookup(id: &str) -> Option<&'static FilterField> { + FILTER_FIELD_REGISTRY.iter().find(|f| f.id == id) +} + +/// `true` when this filter field is applicable for a request that targets +/// the given entity. Per §5.13.3 the routing rule is a *skip*, not an error: +/// if the request asks for `entityTypes = [album, artist]` and a clause names +/// a track-only field, the clause is silently dropped. +pub fn applies_to(field: &FilterField, entity: EntityKind) -> bool { + field.entities.contains(&entity) +} + +// ── SQL fragment resolution (§5.13.5) ───────────────────────────────── + +/// A resolved WHERE-clause snippet plus the values it binds. The builder +/// appends fragments in order and binds their params left-to-right against +/// anonymous `?` placeholders (`params_from_iter`), so a fragment must keep +/// its `sql` placeholders and `params` in the same order. +/// +/// `sql` only ever contains builder-supplied column expressions and literal +/// operators — never user input (spec §5.13.5: parameterised only). +#[derive(Debug, Clone, PartialEq)] +pub struct SqlFragment { + pub sql: String, + pub params: Vec, +} + +/// Why a `LibraryFilterClause` could not be turned into SQL. Surfaced to the +/// command as a human-readable string; `UnknownField` carries the known-field +/// list for dev builds (§5.13.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilterError { + /// `field` is not in the registry at all (typo / wrong client). + UnknownField(String), + /// `field` is registered but has no v1 SQL builder yet (`user_rating`, + /// `suffix`, `bit_rate`, …). Distinct from `UnknownField` so the caller + /// can tell a typo from a planned-but-unbuilt field. + NotQueryable(String), + /// `op` is not declared for `field` in the registry. + UnsupportedOp { field: String, op: &'static str }, + /// The value side was missing or the wrong type for the operator. + BadValue { field: String, detail: String }, +} + +impl std::fmt::Display for FilterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FilterError::UnknownField(field) => { + let known = FILTER_FIELD_REGISTRY + .iter() + .map(|x| x.id) + .collect::>() + .join(", "); + write!(f, "unknown filter field `{field}` (known: {known})") + } + FilterError::NotQueryable(field) => { + write!(f, "filter field `{field}` is registered but not queryable in v1") + } + FilterError::UnsupportedOp { field, op } => { + write!(f, "operator `{op}` is not supported for filter field `{field}`") + } + FilterError::BadValue { field, detail } => { + write!(f, "bad value for filter field `{field}`: {detail}") + } + } + } +} + +/// Validate that `field` exists, applies to `entity`, and declares `op`. +/// +/// Returns `Ok(true)` when the clause is applicable, `Ok(false)` when the +/// field is known but doesn't route to this entity (§5.13.3: skip, don't +/// error), and `Err` when the field is unknown or the op is undeclared. +pub fn validate_for_entity( + field_id: &str, + op: FilterOp, + entity: EntityKind, +) -> Result { + let field = lookup(field_id).ok_or_else(|| FilterError::UnknownField(field_id.to_string()))?; + if !field.ops.contains(&op) { + return Err(FilterError::UnsupportedOp { + field: field_id.to_string(), + op: op.as_str(), + }); + } + Ok(applies_to(field, entity)) +} + +/// Build a comparison fragment for a builder-supplied column expression. +/// `col` is trusted SQL (never user input); only the bound values come from +/// the request. `eq`/`gte`/`lte`/`between` parameterise their operands; +/// `is_true` ignores the value and tests `IS NOT NULL`. `between` is +/// inclusive on both ends (matches the year UI — §5.13.5). +pub fn compare_fragment( + field: &str, + col: &str, + op: FilterOp, + value: Option, + value_to: Option, +) -> Result { + let need_value = |v: Option| { + v.ok_or_else(|| FilterError::BadValue { + field: field.to_string(), + detail: format!("operator `{}` requires a value", op.as_str()), + }) + }; + match op { + FilterOp::Eq => Ok(SqlFragment { + sql: format!("{col} = ?"), + params: vec![need_value(value)?], + }), + FilterOp::Gte => Ok(SqlFragment { + sql: format!("{col} >= ?"), + params: vec![need_value(value)?], + }), + FilterOp::Lte => Ok(SqlFragment { + sql: format!("{col} <= ?"), + params: vec![need_value(value)?], + }), + FilterOp::Between => { + let lo = need_value(value)?; + let hi = value_to.ok_or_else(|| FilterError::BadValue { + field: field.to_string(), + detail: "operator `between` requires `valueTo`".to_string(), + })?; + Ok(SqlFragment { + sql: format!("{col} BETWEEN ? AND ?"), + params: vec![lo, hi], + }) + } + FilterOp::IsTrue => Ok(SqlFragment { + sql: format!("{col} IS NOT NULL"), + params: vec![], + }), + FilterOp::Fts | FilterOp::In => Err(FilterError::UnsupportedOp { + field: field.to_string(), + op: op.as_str(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_contains_all_v1_fields() { + for id in ["text", "genre", "year", "starred"] { + let f = lookup(id).unwrap_or_else(|| panic!("missing v1 field `{id}`")); + assert_eq!(f.status, FilterStatus::V1, "`{id}` must be V1"); + } + } + + #[test] + fn bpm_is_schema_v1_but_ui_later() { + // §5.13.3: bpm has a hot column + index from day one, but is hidden + // from the v1 UI until §5.13.4 dual-storage resolution lands. + assert_eq!(lookup("bpm").unwrap().status, FilterStatus::SchemaV1UiLater); + } + + #[test] + fn text_routes_to_all_three_entities() { + let f = lookup("text").unwrap(); + assert!(applies_to(f, EntityKind::Track)); + assert!(applies_to(f, EntityKind::Album)); + assert!(applies_to(f, EntityKind::Artist)); + } + + #[test] + fn track_only_field_is_skipped_for_album_entity() { + let f = lookup("user_rating").unwrap(); + assert!(applies_to(f, EntityKind::Track)); + assert!(!applies_to(f, EntityKind::Album)); + assert!(!applies_to(f, EntityKind::Artist)); + } + + #[test] + fn unknown_field_lookup_returns_none() { + assert!(lookup("nope").is_none()); + } + + #[test] + fn registry_has_no_duplicate_ids() { + let mut ids: Vec<&str> = FILTER_FIELD_REGISTRY.iter().map(|f| f.id).collect(); + ids.sort(); + let len_before = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), len_before, "duplicate filter field id detected"); + } + + // ── SQL fragment resolution ─────────────────────────────────────── + + #[test] + fn op_wire_roundtrips() { + for op in [ + FilterOp::Fts, + FilterOp::Eq, + FilterOp::In, + FilterOp::Gte, + FilterOp::Lte, + FilterOp::Between, + FilterOp::IsTrue, + ] { + assert_eq!(FilterOp::from_wire(op.as_str()), Some(op)); + } + } + + #[test] + fn op_from_wire_rejects_unbuilt_operators() { + // Spec §5.13.2 lists these but the v1 builder doesn't implement them. + for wire in ["neq", "contains", "is_false", "nope"] { + assert!(FilterOp::from_wire(wire).is_none(), "`{wire}` must not parse"); + } + } + + #[test] + fn validate_unknown_field_errors() { + let err = validate_for_entity("nope", FilterOp::Eq, EntityKind::Track).unwrap_err(); + assert!(matches!(err, FilterError::UnknownField(f) if f == "nope")); + } + + #[test] + fn validate_undeclared_op_errors() { + // `genre` only declares `eq` in v1. + let err = validate_for_entity("genre", FilterOp::Gte, EntityKind::Track).unwrap_err(); + assert!(matches!(err, FilterError::UnsupportedOp { .. })); + } + + #[test] + fn validate_known_field_off_entity_is_skip_not_error() { + // `bpm` is track-only — for an album query it routes to "skip". + assert_eq!( + validate_for_entity("bpm", FilterOp::Between, EntityKind::Album), + Ok(false) + ); + assert_eq!( + validate_for_entity("bpm", FilterOp::Between, EntityKind::Track), + Ok(true) + ); + } + + #[test] + fn compare_fragment_between_is_inclusive_both_ends() { + use rusqlite::types::Value; + let frag = compare_fragment( + "year", + "t.year", + FilterOp::Between, + Some(Value::Integer(2000)), + Some(Value::Integer(2010)), + ) + .unwrap(); + assert_eq!(frag.sql, "t.year BETWEEN ? AND ?"); + assert_eq!(frag.params, vec![Value::Integer(2000), Value::Integer(2010)]); + } + + #[test] + fn compare_fragment_between_without_value_to_errors() { + use rusqlite::types::Value; + let err = compare_fragment( + "year", + "t.year", + FilterOp::Between, + Some(Value::Integer(2000)), + None, + ) + .unwrap_err(); + assert!(matches!(err, FilterError::BadValue { .. })); + } + + #[test] + fn compare_fragment_is_true_ignores_value() { + let frag = compare_fragment("starred", "t.starred_at", FilterOp::IsTrue, None, None).unwrap(); + assert_eq!(frag.sql, "t.starred_at IS NOT NULL"); + assert!(frag.params.is_empty()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs new file mode 100644 index 00000000..abbdb5d6 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -0,0 +1,32 @@ +//! `psysonic-library` — unified track store and (future) sync engine. +//! +//! v1 scope (this crate, across PR-1..PR-7): +//! - `store` — SQLite connection, WAL config, versioned migration runner +//! - `repos` — typed repositories over the v1 schema (track, album, artist, …) +//! - `search` — FTS5 query helpers +//! - `filter` — `FilterFieldRegistry` (Rust source of truth for Advanced Search) +//! - `sync` — capability probe + orchestrator (PR-3*) + +pub(crate) mod bulk_ingest; +pub mod advanced_search; +pub mod canonical; +pub mod commands; +pub mod cross_server; +pub mod dto; +pub mod filter; +pub mod live_search; +pub mod payload; +pub mod repos; +pub mod runtime; +pub mod search; +pub mod store; +pub mod sync; +pub(crate) mod track_fts; + +pub use payload::LibrarySyncProgressPayload; +pub use runtime::LibraryRuntime; + +pub use store::{LibraryStore, LIBRARY_DB_SCHEMA_VERSION}; + +// Re-export logging facade so submodules can write `crate::app_eprintln!()`. +pub use psysonic_core::{app_deprintln, app_eprintln, logging}; diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs new file mode 100644 index 00000000..aa2e5e38 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -0,0 +1,660 @@ +//! Live Search dropdown (spec §5.9 / P24) — column-scoped FTS with LIMIT inside +//! the FTS subquery (bm25 on ≤N rowids), then a cheap join to `track`. +//! Avoids the SQLite pitfall where `JOIN track … ORDER BY bm25` on an OR +//! MATCH scans/ranks the whole hit set on 100k+ libraries (10–20s queries). + +use std::collections::{HashMap, HashSet}; + +use rusqlite::params; + +use crate::dto::{LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryTrackDto}; +use crate::search::{fts_column_prefix_query, fts_query_meets_min_len, library_scope_equals_sql}; +use crate::store::LibraryStore; + +const SONG_FTS_COLUMNS: [&str; 4] = ["title", "artist", "album", "album_artist"]; +const ALBUM_FTS_COLUMNS: [&str; 2] = ["album", "album_artist"]; + +struct LiveHit { + track: LibraryTrackDto, +} + +/// `library_live_search` — read connection, scoped FTS rowid picks + join. +pub fn run_live_search( + store: &LibraryStore, + server_id: &str, + query: &str, + library_scope: Option<&str>, + artist_limit: u32, + album_limit: u32, + song_limit: u32, +) -> Result { + if !fts_query_meets_min_len(query) { + return Ok(LibraryLiveSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + source: "local".to_string(), + }); + } + + store.with_read_conn(|conn| { + let scope = trimmed_scope(library_scope); + let songs = query_songs(conn, query, server_id, scope.as_deref(), song_limit)?; + let artists = query_artists(conn, query, server_id, scope.as_deref(), artist_limit)?; + let albums = query_albums(conn, query, server_id, scope.as_deref(), album_limit)?; + Ok(LibraryLiveSearchResponse { + artists, + albums, + tracks: songs, + source: "local".to_string(), + }) + }) +} + +/// Top FTS rowids for one or more column-scoped MATCH strings (deduped, ordered). +fn collect_fts_rowids( + conn: &rusqlite::Connection, + match_queries: &[String], + per_query_limit: i64, + total_limit: usize, +) -> rusqlite::Result> { + let sql = + "SELECT rowid FROM track_fts WHERE track_fts MATCH ?1 ORDER BY bm25(track_fts) LIMIT ?2"; + let mut stmt = conn.prepare(sql)?; + let mut seen = HashSet::new(); + let mut rowids = Vec::new(); + for mq in match_queries { + let rows = stmt.query_map(params![mq, per_query_limit], |r| r.get(0))?; + for rowid in rows { + let rowid = rowid?; + if seen.insert(rowid) { + rowids.push(rowid); + if rowids.len() >= total_limit { + return Ok(rowids); + } + } + } + } + Ok(rowids) +} + +fn column_matches(query: &str, columns: &[&str]) -> Result, String> { + columns + .iter() + .map(|col| { + fts_column_prefix_query(col, query).ok_or_else(|| "empty query".to_string()) + }) + .collect() +} + +fn trimmed_scope(scope: Option<&str>) -> Option { + scope + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn append_library_scope( + sql: &mut String, + params: &mut Vec, + library_scope: Option<&str>, +) { + if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { + sql.push_str(" AND "); + sql.push_str(&library_scope_equals_sql("t")); + params.push(rusqlite::types::Value::Text(scope.to_string())); + } +} + +fn query_songs( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let matches = column_matches(query, &SONG_FTS_COLUMNS).map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e, + ))) + })?; + let per_col = i64::from(limit.max(4)); + let rowids = collect_fts_rowids(conn, &matches, per_col, limit as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + fetch_tracks_by_rowids(conn, &rowids, server_id, library_scope) +} + +fn query_artists( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let Some(artist_fts) = fts_column_prefix_query("artist", query) else { + return Ok(Vec::new()); + }; + let fetch = limit.saturating_mul(3).clamp(limit, 24); + let rowids = collect_fts_rowids(conn, &[artist_fts], i64::from(fetch), fetch as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + let placeholders = rowid_placeholders(rowids.len()); + let sql = format!( + "SELECT t.server_id, t.artist_id, t.artist, t.synced_at, t.rowid \ + FROM track t \ + WHERE t.rowid IN ({placeholders}) \ + AND t.server_id = ? \ + AND t.deleted = 0 \ + AND t.artist_id IS NOT NULL AND t.artist_id != ''" + ); + let mut params: Vec = rowids + .iter() + .copied() + .map(rusqlite::types::Value::Integer) + .collect(); + params.push(rusqlite::types::Value::Text(server_id.to_string())); + let mut sql = sql; + append_library_scope(&mut sql, &mut params, library_scope); + let rank: HashMap = rowids + .iter() + .enumerate() + .map(|(i, &rid)| (rid, i)) + .collect(); + let mut ranked: Vec<(usize, LibraryArtistDto)> = Vec::new(); + let mut stmt = conn.prepare(&sql)?; + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get::<_, i64>(4)?, + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, i64>(3)?, + )) + })? { + let (rowid, server_id, artist_id, artist, synced_at) = row?; + let fts_rank = rank.get(&rowid).copied().unwrap_or(usize::MAX); + ranked.push(( + fts_rank, + LibraryArtistDto { + server_id, + id: artist_id, + name: artist.unwrap_or_default(), + album_count: None, + synced_at, + raw_json: serde_json::Value::Null, + }, + )); + } + ranked.sort_by_key(|(r, _)| *r); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for (_, dto) in ranked { + if !seen.insert(dto.id.clone()) { + continue; + } + out.push(dto); + if out.len() >= limit as usize { + break; + } + } + Ok(out) +} + +fn query_albums( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let matches = column_matches(query, &ALBUM_FTS_COLUMNS).map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e, + ))) + })?; + let fetch = limit.saturating_mul(3).clamp(limit, 24); + let rowids = collect_fts_rowids(conn, &matches, i64::from(fetch), fetch as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + let placeholders = rowid_placeholders(rowids.len()); + let sql = format!( + "SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ + t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.rowid \ + FROM track t \ + WHERE t.rowid IN ({placeholders}) \ + AND t.server_id = ? \ + AND t.deleted = 0 \ + AND t.album_id IS NOT NULL AND t.album_id != ''" + ); + let mut params: Vec = rowids + .iter() + .copied() + .map(rusqlite::types::Value::Integer) + .collect(); + params.push(rusqlite::types::Value::Text(server_id.to_string())); + let mut sql = sql; + append_library_scope(&mut sql, &mut params, library_scope); + let rank: HashMap = rowids + .iter() + .enumerate() + .map(|(i, &rid)| (rid, i)) + .collect(); + let mut ranked: Vec<(usize, LibraryAlbumDto)> = Vec::new(); + let mut stmt = conn.prepare(&sql)?; + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get::<_, i64>(10)?, + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, i64>(9)?, + )) + })? { + let ( + rowid, + server_id, + album_id, + album, + artist, + artist_id, + year, + genre, + cover_art_id, + starred_at, + synced_at, + ) = row?; + let fts_rank = rank.get(&rowid).copied().unwrap_or(usize::MAX); + ranked.push(( + fts_rank, + LibraryAlbumDto { + server_id, + id: album_id, + name: album, + artist, + artist_id, + song_count: None, + duration_sec: None, + year, + genre, + cover_art_id, + starred_at, + synced_at, + raw_json: serde_json::Value::Null, + }, + )); + } + ranked.sort_by_key(|(r, _)| *r); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for (_, dto) in ranked { + if !seen.insert(dto.id.clone()) { + continue; + } + out.push(dto); + if out.len() >= limit as usize { + break; + } + } + Ok(out) +} + +fn rowid_placeholders(n: usize) -> String { + (0..n).map(|_| "?").collect::>().join(", ") +} + +fn fetch_tracks_by_rowids( + conn: &rusqlite::Connection, + rowids: &[i64], + server_id: &str, + library_scope: Option<&str>, +) -> rusqlite::Result> { + let placeholders = rowid_placeholders(rowids.len()); + let sql = format!( + "SELECT \ + t.rowid, \ + t.server_id, t.id, t.title, t.artist, t.artist_id, t.album, t.album_id, \ + t.album_artist, t.duration_sec, t.track_number, t.disc_number, t.year, \ + t.genre, t.suffix, t.bit_rate, t.size_bytes, t.cover_art_id, \ + t.starred_at, t.user_rating, t.play_count, t.bpm, t.synced_at \ + FROM track t \ + WHERE t.rowid IN ({placeholders}) \ + AND t.server_id = ? \ + AND t.deleted = 0" + ); + let mut params: Vec = rowids + .iter() + .copied() + .map(rusqlite::types::Value::Integer) + .collect(); + params.push(rusqlite::types::Value::Text(server_id.to_string())); + let mut sql = sql; + append_library_scope(&mut sql, &mut params, library_scope); + let mut stmt = conn.prepare(&sql)?; + let mut by_rowid: HashMap = HashMap::new(); + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + let rowid: i64 = r.get(0)?; + let hit = map_live_hit_row(r, 1)?; + Ok((rowid, hit.track)) + })? { + let (rowid, track) = row?; + by_rowid.insert(rowid, track); + } + Ok(rowids + .iter() + .filter_map(|rid| by_rowid.get(rid).cloned()) + .collect()) +} + +fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result { + Ok(LiveHit { + track: LibraryTrackDto { + server_id: row.get(offset)?, + id: row.get(offset + 1)?, + content_hash: None, + title: row.get(offset + 2)?, + title_sort: None, + artist: row.get(offset + 3)?, + artist_id: row.get(offset + 4)?, + album: row.get(offset + 5)?, + album_id: row.get(offset + 6)?, + album_artist: row.get(offset + 7)?, + duration_sec: row.get(offset + 8)?, + track_number: row.get(offset + 9)?, + disc_number: row.get(offset + 10)?, + year: row.get(offset + 11)?, + genre: row.get(offset + 12)?, + suffix: row.get(offset + 13)?, + bit_rate: row.get(offset + 14)?, + size_bytes: row.get(offset + 15)?, + cover_art_id: row.get(offset + 16)?, + starred_at: row.get(offset + 17)?, + user_rating: row.get(offset + 18)?, + play_count: row.get(offset + 19)?, + bpm: row.get(offset + 20)?, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + server_updated_at: None, + server_created_at: None, + synced_at: row.get(offset + 21)?, + enrichment: None, + raw_json: serde_json::Value::Null, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + use serde_json::json; + + fn track( + server: &str, + id: &str, + title: &str, + artist: &str, + album: &str, + album_id: &str, + artist_id: &str, + ) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(artist_id.into()), + album: album.into(), + album_id: Some(album_id.into()), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: Some(format!("cv_{album_id}")), + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn live_search_prefix_matches_partial_artist_name() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track( + "s1", + "t1", + "Enter Sandman", + "Metallica", + "Metallica", + "al1", + "ar_meta", + ), + track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "metal", None, 5, 5, 10).unwrap(); + assert!( + resp.artists.iter().any(|a| a.name == "Metallica"), + "expected Metallica from prefix query metal" + ); + assert!(resp.tracks.iter().any(|t| t.artist.as_deref() == Some("Metallica"))); + } + + #[test] + fn live_search_returns_songs_albums_artists_from_scoped_fts() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Aurora Song", "Aurora Quartet", "Aurora Nights", "al1", "ar1"), + track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "aurora", None, 5, 5, 10).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al1"); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar1"); + assert!(resp.tracks[0].raw_json.is_null()); + } + + #[test] + fn live_search_does_not_surface_artist_from_unrelated_track_hit() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track( + "s1", + "t1", + "Battle Hymn", + "Arch Enemy", + "Manowar Covers Vol 1", + "al1", + "ar_arch", + ), + track( + "s1", + "t2", + "Heart Of Steel", + "Manowar", + "Fighting the World", + "al2", + "ar_mano", + ), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "manowar", None, 5, 5, 10).unwrap(); + assert!( + resp.artists.iter().any(|a| a.name == "Manowar"), + "expected Manowar artist" + ); + assert!( + !resp.artists.iter().any(|a| a.name == "Arch Enemy"), + "Arch Enemy must not appear when only the album title mentions Manowar" + ); + assert!(resp.albums.iter().any(|a| a.name.contains("Manowar"))); + } + + #[test] + fn live_search_short_query_returns_empty_without_scanning() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track( + "s1", + "t1", + "Аура", + "Artist", + "Album", + "al1", + "ar1", + )]) + .unwrap(); + let resp = run_live_search(&store, "s1", "а", None, 5, 5, 10).unwrap(); + assert!(resp.tracks.is_empty()); + assert!(resp.artists.is_empty()); + assert!(resp.albums.is_empty()); + } + + #[test] + fn live_search_library_scope_narrows_results() { + let store = LibraryStore::open_in_memory(); + let mut in_lib = track( + "s1", + "t1", + "Scoped Song", + "Scoped Artist", + "Scoped Album", + "al1", + "ar1", + ); + in_lib.library_id = Some("lib1".into()); + let mut other = track( + "s1", + "t2", + "Scoped Song", + "Other Artist", + "Other Album", + "al2", + "ar2", + ); + other.library_id = Some("lib2".into()); + TrackRepository::new(&store) + .upsert_batch(&[in_lib, other]) + .unwrap(); + let resp = run_live_search(&store, "s1", "scoped", Some("lib1"), 5, 5, 10).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].name, "Scoped Artist"); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].name, "Scoped Album"); + } + + #[test] + fn live_search_library_scope_matches_raw_json_when_column_null() { + let store = LibraryStore::open_in_memory(); + let mut row = track( + "s1", + "t1", + "Scoped Song", + "Scoped Artist", + "Scoped Album", + "al1", + "ar1", + ); + row.raw_json = json!({"libraryId": 3}).to_string(); + TrackRepository::new(&store).upsert_batch(&[row]).unwrap(); + let resp = run_live_search(&store, "s1", "scoped", Some("3"), 5, 5, 10).unwrap(); + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].id, "t1"); + } + + /// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture` + #[test] + #[ignore] + fn bench_disk_live_search() { + use std::path::PathBuf; + use std::time::Instant; + + let path: PathBuf = std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".local/share/dev.psysonic.player/library.sqlite")) + .expect("HOME"); + if !path.exists() { + eprintln!("skip: no db at {}", path.display()); + return; + } + let conn = rusqlite::Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .expect("open db"); + conn.pragma_update(None, "cache_size", -64000).unwrap(); + + let server_id = std::env::var("PSYSONIC_BENCH_SERVER_ID").unwrap_or_else(|_| { + conn.query_row( + "SELECT server_id FROM track WHERE deleted = 0 LIMIT 1", + [], + |r| r.get::<_, String>(0), + ) + .expect("server_id") + }); + + for q in ["manowar", "metallica", "arch enemy", "metal", "meta"] { + let t0 = Instant::now(); + let songs = query_songs(&conn, q, &server_id, None, 10).unwrap(); + let t1 = Instant::now(); + let artists = query_artists(&conn, q, &server_id, None, 5).unwrap(); + let t2 = Instant::now(); + let albums = query_albums(&conn, q, &server_id, None, 5).unwrap(); + let t3 = Instant::now(); + eprintln!( + "{q:?}: songs={} ({:.1}ms) artists={} ({:.1}ms) albums={} ({:.1}ms) total={:.1}ms", + songs.len(), + t1.duration_since(t0).as_secs_f64() * 1000.0, + artists.len(), + t2.duration_since(t1).as_secs_f64() * 1000.0, + albums.len(), + t3.duration_since(t2).as_secs_f64() * 1000.0, + t3.duration_since(t0).as_secs_f64() * 1000.0, + ); + } + } +} diff --git a/src-tauri/crates/psysonic-library/src/payload.rs b/src-tauri/crates/psysonic-library/src/payload.rs new file mode 100644 index 00000000..cc5fb800 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/payload.rs @@ -0,0 +1,240 @@ +//! `ProgressEvent` → Tauri event payload mapper. PR-5a ships the +//! transformation as a pure function so it's unit-testable without +//! Tauri / supervisor wiring; PR-5b plugs the mpsc receiver into +//! `AppHandle::emit("library:sync-progress", payload)`. + +use serde::{Deserialize, Serialize}; + +use crate::sync::progress::ProgressEvent; + +/// Wire shape for the `library:sync-progress` / `library:sync-idle` +/// Tauri events. Carries the `serverId` + `libraryScope` so the +/// frontend can demultiplex across multiple servers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibrarySyncProgressPayload { + pub server_id: String, + pub library_scope: String, + /// Discriminator: `"phase_changed"` / `"ingest_page"` / + /// `"remapped"` / `"tombstoned"` / `"completed"` / `"error"`. + pub kind: String, + pub phase: Option, + pub ingested_total: Option, + pub batch_count: Option, + pub remapped_count: Option, + pub tombstones_checked: Option, + pub tombstones_deleted: Option, + pub completed_kind: Option, + pub message: Option, + /// Per-batch ingest timings (S1 initial sync). + pub ingest_metrics: Option, +} + +impl LibrarySyncProgressPayload { + pub fn from_event(event: &ProgressEvent, server_id: &str, library_scope: &str) -> Self { + let mut payload = Self { + server_id: server_id.to_string(), + library_scope: library_scope.to_string(), + kind: String::new(), + phase: None, + ingested_total: None, + batch_count: None, + remapped_count: None, + tombstones_checked: None, + tombstones_deleted: None, + completed_kind: None, + message: None, + ingest_metrics: None, + }; + match event { + ProgressEvent::PhaseChanged { phase } => { + payload.kind = "phase_changed".into(); + payload.phase = Some(phase.clone()); + } + ProgressEvent::IngestPage { + ingested_total, + batch_count, + metrics, + } => { + payload.kind = "ingest_page".into(); + payload.ingested_total = Some(*ingested_total); + payload.batch_count = Some(*batch_count); + payload.ingest_metrics = metrics.clone(); + } + ProgressEvent::Remapped { entries } => { + payload.kind = "remapped".into(); + payload.remapped_count = Some(entries.len() as u32); + } + ProgressEvent::Tombstoned { + deleted_count, + checked_count, + } => { + payload.kind = "tombstoned".into(); + payload.tombstones_deleted = Some(*deleted_count); + payload.tombstones_checked = Some(*checked_count); + } + ProgressEvent::Completed { kind } => { + payload.kind = "completed".into(); + payload.completed_kind = Some(kind.clone()); + } + ProgressEvent::Error { message } => { + payload.kind = "error".into(); + payload.message = Some(message.clone()); + } + } + payload + } + + /// Convenience constant for the event-name Tauri emits. + pub const PROGRESS_EVENT_NAME: &'static str = "library:sync-progress"; + pub const IDLE_EVENT_NAME: &'static str = "library:sync-idle"; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::RemapEntry; + + #[test] + fn phase_changed_maps_to_phase_kind() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::PhaseChanged { phase: "ingest".into() }, + "s1", + "", + ); + assert_eq!(p.kind, "phase_changed"); + assert_eq!(p.phase.as_deref(), Some("ingest")); + assert_eq!(p.server_id, "s1"); + } + + #[test] + fn ingest_page_carries_total_and_batch_count() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::IngestPage { + ingested_total: 2500, + batch_count: 5, + metrics: None, + }, + "s1", + "lib-1", + ); + assert_eq!(p.kind, "ingest_page"); + assert_eq!(p.ingested_total, Some(2500)); + assert_eq!(p.batch_count, Some(5)); + assert_eq!(p.library_scope, "lib-1"); + } + + #[test] + fn remapped_count_reflects_entries_length() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Remapped { + entries: vec![ + RemapEntry { + server_id: "s1".into(), + old_id: "a".into(), + new_id: "b".into(), + }, + RemapEntry { + server_id: "s1".into(), + old_id: "c".into(), + new_id: "d".into(), + }, + ], + }, + "s1", + "", + ); + assert_eq!(p.kind, "remapped"); + assert_eq!(p.remapped_count, Some(2)); + } + + #[test] + fn tombstoned_carries_checked_and_deleted() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Tombstoned { + deleted_count: 3, + checked_count: 10, + }, + "s1", + "", + ); + assert_eq!(p.kind, "tombstoned"); + assert_eq!(p.tombstones_deleted, Some(3)); + assert_eq!(p.tombstones_checked, Some(10)); + } + + #[test] + fn completed_event_records_kind_string() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Completed { kind: "initial_sync".into() }, + "s1", + "", + ); + assert_eq!(p.kind, "completed"); + assert_eq!(p.completed_kind.as_deref(), Some("initial_sync")); + } + + #[test] + fn error_event_records_message() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::Error { message: "timeout".into() }, + "s1", + "", + ); + assert_eq!(p.kind, "error"); + assert_eq!(p.message.as_deref(), Some("timeout")); + } + + #[test] + fn ingest_metrics_serialize_camel_case() { + use crate::sync::progress::IngestBatchMetrics; + + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::IngestPage { + ingested_total: 500, + batch_count: 1, + metrics: Some(IngestBatchMetrics { + offset: 4500, + strategy: "s1".into(), + fetch_ms: 120, + write_ms: 8, + lock_wait_ms: 0, + sql_exec_ms: 7, + persist_ms: 1, + row_count: 500, + bulk_ingest_active: true, + }), + }, + "s1", + "", + ); + let json = serde_json::to_value(&p).unwrap(); + let metrics = json.get("ingestMetrics").unwrap(); + assert_eq!(metrics.get("fetchMs").and_then(|v| v.as_u64()), Some(120)); + assert_eq!(metrics.get("lockWaitMs").and_then(|v| v.as_u64()), Some(0)); + assert_eq!(metrics.get("bulkIngestActive").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn serialization_uses_camel_case_keys() { + let p = LibrarySyncProgressPayload::from_event( + &ProgressEvent::IngestPage { + ingested_total: 1, + batch_count: 1, + metrics: None, + }, + "s1", + "", + ); + let json = serde_json::to_value(&p).unwrap(); + for key in [ + "serverId", + "libraryScope", + "kind", + "ingestedTotal", + "batchCount", + ] { + assert!(json.get(key).is_some(), "missing camelCase key `{key}`"); + } + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/artifact.rs b/src-tauri/crates/psysonic-library/src/repos/artifact.rs new file mode 100644 index 00000000..54a2beeb --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/artifact.rs @@ -0,0 +1,453 @@ +//! E4 — `ArtifactRepository`: typed CRUD over `track_artifact` with the +//! §5.11 / §5.12 storage rules. +//! +//! - **Lazy expiry (P34):** no background GC; a `get` first deletes the +//! track's expired artifacts of that kind (negative-cache rows included), +//! then returns the best surviving match. +//! - **Size cap (§5.12 write path):** an artifact over 512 KB is rejected +//! unless it is a user import (`source_kind == "user"`), so a runaway +//! lyrics/blob fetch can't bloat `library.sqlite`. +//! +//! The Tauri commands (`library_get_artifact` / `library_put_artifact`) +//! delegate here so there's one source of truth for the storage rules. + +use rusqlite::types::Value; +use rusqlite::{params, OptionalExtension}; + +use crate::dto::{ArtifactInputDto, TrackArtifactDto}; +use crate::store::LibraryStore; + +/// §5.12: cap a single artifact at 512 KB unless the user imported it. +pub const MAX_ARTIFACT_BYTES: usize = 512 * 1024; + +pub struct ArtifactRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> ArtifactRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Lazily drop the track's expired artifacts of this kind (§5.12 + /// read-path GC — negative-cache rows expire too), then return the most + /// recent surviving match. `source_kind` / `source_id` / `format` narrow + /// the lookup when provided so the lyrics path can resolve a specific + /// source or take the first match. + // Mirrors the flat parameter list of the `library_get_artifact` Tauri + // command (a fixed public contract); grouping them here would only + // diverge the two signatures. + #[allow(clippy::too_many_arguments)] + pub fn get( + &self, + server_id: &str, + track_id: &str, + artifact_kind: &str, + source_kind: Option<&str>, + source_id: Option<&str>, + format: Option<&str>, + now: i64, + ) -> Result, String> { + if self.store.bulk_ingest_active() { + return self.get_readonly( + server_id, + track_id, + artifact_kind, + source_kind, + source_id, + format, + ); + } + self.store + .with_conn_mut("artifact.get_gc", |conn| { + // Lazy TTL cleanup, scoped to the looked-up kind. + conn.execute( + "DELETE FROM track_artifact \ + WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3 \ + AND expires_at IS NOT NULL AND expires_at < ?4", + params![server_id, track_id, artifact_kind, now], + )?; + + Self::query_one( + conn, + server_id, + track_id, + artifact_kind, + source_kind, + source_id, + format, + ) + }) + .map_err(|e| e.to_string()) + } + + fn get_readonly( + &self, + server_id: &str, + track_id: &str, + artifact_kind: &str, + source_kind: Option<&str>, + source_id: Option<&str>, + format: Option<&str>, + ) -> Result, String> { + self.store + .with_read_conn(|conn| { + Self::query_one( + conn, + server_id, + track_id, + artifact_kind, + source_kind, + source_id, + format, + ) + }) + .map_err(|e| e.to_string()) + } + + fn query_one( + conn: &rusqlite::Connection, + server_id: &str, + track_id: &str, + artifact_kind: &str, + source_kind: Option<&str>, + source_id: Option<&str>, + format: Option<&str>, + ) -> rusqlite::Result> { + let mut sql = String::from( + "SELECT server_id, track_id, artifact_kind, format, source_kind, source_id, \ + language, content_text, content_bytes, not_found, content_hash, fetched_at, \ + expires_at FROM track_artifact \ + WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3", + ); + let mut bound: Vec = vec![ + Value::Text(server_id.to_string()), + Value::Text(track_id.to_string()), + Value::Text(artifact_kind.to_string()), + ]; + let mut next = 4; + if let Some(sk) = source_kind { + sql.push_str(&format!(" AND source_kind = ?{next}")); + bound.push(Value::Text(sk.to_string())); + next += 1; + } + if let Some(si) = source_id { + sql.push_str(&format!(" AND source_id = ?{next}")); + bound.push(Value::Text(si.to_string())); + next += 1; + } + if let Some(fmt) = format { + sql.push_str(&format!(" AND format = ?{next}")); + bound.push(Value::Text(fmt.to_string())); + } + sql.push_str(" ORDER BY fetched_at DESC LIMIT 1"); + + let mut stmt = conn.prepare(&sql)?; + stmt.query_row(rusqlite::params_from_iter(bound.iter()), row_to_artifact_dto) + .optional() + } + + /// E3 readiness: is there a valid (non-expired, non-`not_found`) lyrics + /// artifact for `(server_id, track_id)`? Pure read — no lazy GC, no writes — + /// so the `library_get_track` enrichment summary stays read-only. + pub fn lyrics_cached(&self, server_id: &str, track_id: &str, now: i64) -> Result { + let query = |conn: &rusqlite::Connection| { + conn.query_row( + "SELECT EXISTS ( \ + SELECT 1 FROM track_artifact \ + WHERE server_id = ?1 AND track_id = ?2 \ + AND artifact_kind = 'lyrics' \ + AND not_found = 0 \ + AND (expires_at IS NULL OR expires_at >= ?3) \ + )", + params![server_id, track_id, now], + |r| r.get::<_, i64>(0), + ) + }; + let exists = if self.store.bulk_ingest_active() { + self.store.with_read_conn(query)? + } else { + self.store.with_conn("artifact.lyrics_cached", query)? + }; + Ok(exists != 0) + } + + /// Upsert an artifact. Rejects content over [`MAX_ARTIFACT_BYTES`] unless + /// it is a user import (§5.12). A negative-cache row (`not_found`) carries + /// no content, so it always fits. + pub fn put( + &self, + server_id: &str, + track_id: &str, + artifact: &ArtifactInputDto, + now: i64, + ) -> Result<(), String> { + let actual = artifact.content_text.as_ref().map_or(0, |s| s.len()) + + artifact.content_blob.as_ref().map_or(0, |b| b.len()); + let declared = usize::try_from(artifact.content_bytes).unwrap_or(0); + let size = actual.max(declared); + if size > MAX_ARTIFACT_BYTES && artifact.source_kind != "user" { + return Err(format!( + "artifact too large: {size} bytes exceeds {MAX_ARTIFACT_BYTES} cap \ + (source_kind={})", + artifact.source_kind + )); + } + + self.store + .with_conn("artifact.put", |conn| { + conn.execute( + UPSERT_ARTIFACT, + params![ + server_id, + track_id, + artifact.artifact_kind, + artifact.format, + artifact.language, + artifact.source_kind, + artifact.source_id, + artifact.content_text, + artifact.content_blob, + artifact.content_bytes, + if artifact.not_found { 1_i64 } else { 0 }, + artifact.content_hash, + now, + artifact.expires_at, + ], + )?; + Ok(()) + }) + .map_err(|e| e.to_string()) + } +} + +fn row_to_artifact_dto(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackArtifactDto { + server_id: row.get(0)?, + track_id: row.get(1)?, + artifact_kind: row.get(2)?, + format: row.get(3)?, + source_kind: row.get(4)?, + source_id: row.get(5)?, + language: row.get(6)?, + content_text: row.get(7)?, + content_bytes: row.get(8)?, + not_found: row.get::<_, i64>(9)? != 0, + content_hash: row.get(10)?, + fetched_at: row.get(11)?, + expires_at: row.get(12)?, + }) +} + +const UPSERT_ARTIFACT: &str = "INSERT INTO track_artifact \ + (server_id, track_id, artifact_kind, format, language, source_kind, source_id, \ + content_text, content_blob, content_bytes, not_found, content_hash, fetched_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) \ + ON CONFLICT(server_id, track_id, artifact_kind, source_kind, source_id, format) DO UPDATE SET \ + language = excluded.language, \ + content_text = excluded.content_text, \ + content_blob = excluded.content_blob, \ + content_bytes = excluded.content_bytes, \ + not_found = excluded.not_found, \ + content_hash = excluded.content_hash, \ + fetched_at = excluded.fetched_at, \ + expires_at = excluded.expires_at"; + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact( + kind: &str, + format: &str, + source_kind: &str, + source_id: &str, + ) -> ArtifactInputDto { + ArtifactInputDto { + artifact_kind: kind.into(), + format: format.into(), + source_kind: source_kind.into(), + source_id: source_id.into(), + language: None, + content_text: Some("la la la".into()), + content_blob: None, + content_bytes: 0, + not_found: false, + content_hash: None, + expires_at: None, + } + } + + fn seed_track(store: &LibraryStore, server: &str, id: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track (server_id, id, title, synced_at, raw_json) \ + VALUES (?1, ?2, 'T', 1, '{}')", + params![server, id], + ) + }) + .unwrap(); + } + + #[test] + fn put_then_get_roundtrips() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 100) + .unwrap(); + let got = repo + .get("s1", "t1", "lyrics", None, None, None, 200) + .unwrap() + .expect("artifact present"); + assert_eq!(got.artifact_kind, "lyrics"); + assert_eq!(got.content_text.as_deref(), Some("la la la")); + } + + #[test] + fn get_lazily_deletes_expired_artifact() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut a = artifact("lyrics", "plain", "lrclib", "lrclib"); + a.expires_at = Some(150); + repo.put("s1", "t1", &a, 100).unwrap(); + + // read at t=200 → expired, dropped + treated as a miss. + let got = repo.get("s1", "t1", "lyrics", None, None, None, 200).unwrap(); + assert!(got.is_none()); + + let total: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_artifact", [], |r| r.get(0))) + .unwrap(); + assert_eq!(total, 0, "expired row deleted, not just filtered"); + } + + #[test] + fn negative_cache_row_is_returned_until_it_expires() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut miss = artifact("lyrics", "plain", "lrclib", "lrclib"); + miss.content_text = None; + miss.not_found = true; + miss.expires_at = Some(1000); + repo.put("s1", "t1", &miss, 100).unwrap(); + + let got = repo + .get("s1", "t1", "lyrics", None, None, None, 500) + .unwrap() + .expect("negative-cache row still live"); + assert!(got.not_found, "caller sees the cached miss instead of refetching"); + } + + #[test] + fn lyrics_cached_only_for_live_found_row() { + // Live lyrics row → cached; nothing / negative-cache / expired → not. + let live = LibraryStore::open_in_memory(); + seed_track(&live, "s1", "t1"); + let repo = ArtifactRepository::new(&live); + assert!(!repo.lyrics_cached("s1", "t1", 200).unwrap(), "no row → not cached"); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 100) + .unwrap(); + assert!(repo.lyrics_cached("s1", "t1", 200).unwrap(), "live row → cached"); + + let neg = LibraryStore::open_in_memory(); + seed_track(&neg, "s1", "t1"); + let repo_neg = ArtifactRepository::new(&neg); + let mut miss = artifact("lyrics", "plain", "lrclib", "lrclib"); + miss.content_text = None; + miss.not_found = true; + miss.expires_at = Some(1000); + repo_neg.put("s1", "t1", &miss, 100).unwrap(); + assert!( + !repo_neg.lyrics_cached("s1", "t1", 500).unwrap(), + "negative-cache row is not 'cached'" + ); + + let exp = LibraryStore::open_in_memory(); + seed_track(&exp, "s1", "t1"); + let repo_exp = ArtifactRepository::new(&exp); + let mut expired = artifact("lyrics", "plain", "lrclib", "lrclib"); + expired.expires_at = Some(150); + repo_exp.put("s1", "t1", &expired, 100).unwrap(); + assert!( + !repo_exp.lyrics_cached("s1", "t1", 200).unwrap(), + "expired lyrics not cached" + ); + } + + #[test] + fn get_filters_by_source_id_only() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 1) + .unwrap(); + repo.put("s1", "t1", &artifact("lyrics", "plain", "netease", "netease"), 2) + .unwrap(); + + // source_id without source_kind must bind correctly (running indices). + let got = repo + .get("s1", "t1", "lyrics", None, Some("netease"), None, 3) + .unwrap() + .expect("netease row"); + assert_eq!(got.source_id, "netease"); + } + + #[test] + fn get_returns_most_recent_when_unfiltered() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 1) + .unwrap(); + repo.put("s1", "t1", &artifact("lyrics", "plain", "netease", "netease"), 9) + .unwrap(); + let got = repo + .get("s1", "t1", "lyrics", None, None, None, 10) + .unwrap() + .unwrap(); + assert_eq!(got.source_id, "netease", "ORDER BY fetched_at DESC"); + } + + #[test] + fn put_rejects_oversized_non_user_artifact() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut big = artifact("lyrics", "plain", "lrclib", "lrclib"); + big.content_text = Some("x".repeat(MAX_ARTIFACT_BYTES + 1)); + let err = repo.put("s1", "t1", &big, 1).unwrap_err(); + assert!(err.contains("too large"), "got: {err}"); + } + + #[test] + fn put_allows_oversized_user_import() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + let mut big = artifact("lyrics", "plain", "user", "user"); + big.content_text = Some("x".repeat(MAX_ARTIFACT_BYTES + 1)); + repo.put("s1", "t1", &big, 1).expect("user import bypasses the cap"); + } + + #[test] + fn put_is_idempotent_on_same_source_key() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = ArtifactRepository::new(&store); + repo.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 1) + .unwrap(); + let mut updated = artifact("lyrics", "plain", "lrclib", "lrclib"); + updated.content_text = Some("new words".into()); + repo.put("s1", "t1", &updated, 2).unwrap(); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_artifact", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1, "same (kind, source, format) updates in place"); + let got = repo.get("s1", "t1", "lyrics", None, None, None, 3).unwrap().unwrap(); + assert_eq!(got.content_text.as_deref(), Some("new words")); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/fact.rs b/src-tauri/crates/psysonic-library/src/repos/fact.rs new file mode 100644 index 00000000..facee0d2 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/fact.rs @@ -0,0 +1,302 @@ +//! E4 — `FactRepository`: typed CRUD over `track_fact` with the §5.12 +//! provenance / TTL rules. +//! +//! - **Lazy expiry (P34):** there's no background GC; a `get` first deletes +//! the track's expired facts, then returns the survivors. +//! - **User override (R6-3.4):** a `user` BPM fact also writes `track.bpm` so +//! the override wins and survives a server resync. +//! +//! The Tauri commands (`library_get_facts` / `library_put_fact`) delegate +//! here so there's one source of truth for the storage rules. + +use rusqlite::params; + +use crate::dto::{FactInputDto, TrackFactDto}; +use crate::store::LibraryStore; + +pub struct FactRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> FactRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Lazily drop the track's expired facts (§5.12 read-path GC), then + /// return the survivors — optionally filtered to `fact_kinds`. Ordered + /// `fact_kind ASC, fetched_at DESC` so the highest-priority source per + /// kind (caller resolves) comes first within its group. + pub fn get( + &self, + server_id: &str, + track_id: &str, + fact_kinds: &[String], + now: i64, + ) -> Result, String> { + if self.store.bulk_ingest_active() { + return self.get_readonly(server_id, track_id, fact_kinds); + } + self.store + .with_conn_mut("fact.get_gc", |conn| { + // Lazy TTL cleanup for this track. + conn.execute( + "DELETE FROM track_fact \ + WHERE server_id = ?1 AND track_id = ?2 \ + AND expires_at IS NOT NULL AND expires_at < ?3", + params![server_id, track_id, now], + )?; + + Self::query_facts(conn, server_id, track_id, fact_kinds) + }) + .map_err(|e| e.to_string()) + } + + fn get_readonly( + &self, + server_id: &str, + track_id: &str, + fact_kinds: &[String], + ) -> Result, String> { + self.store + .with_read_conn(|conn| Self::query_facts(conn, server_id, track_id, fact_kinds)) + .map_err(|e| e.to_string()) + } + + fn query_facts( + conn: &rusqlite::Connection, + server_id: &str, + track_id: &str, + fact_kinds: &[String], + ) -> rusqlite::Result> { + if fact_kinds.is_empty() { + let mut stmt = conn.prepare(SELECT_FACTS)?; + let rows: rusqlite::Result> = stmt + .query_map(params![server_id, track_id], row_to_fact_dto)? + .collect(); + rows + } else { + let placeholders = (0..fact_kinds.len()) + .map(|i| format!("?{}", i + 3)) + .collect::>() + .join(", "); + let sql = format!( + "{SELECT_FACTS_BASE} AND fact_kind IN ({placeholders}) \ + ORDER BY fact_kind ASC, fetched_at DESC" + ); + let mut bound: Vec = vec![ + rusqlite::types::Value::Text(server_id.to_string()), + rusqlite::types::Value::Text(track_id.to_string()), + ]; + for k in fact_kinds { + bound.push(rusqlite::types::Value::Text(k.clone())); + } + let mut stmt = conn.prepare(&sql)?; + let rows: rusqlite::Result> = stmt + .query_map(rusqlite::params_from_iter(bound.iter()), row_to_fact_dto)? + .collect(); + rows + } + } + + /// Upsert a fact. A `user` BPM fact also writes the hot `track.bpm` + /// column so the manual override beats the server tag and survives a + /// resync (§5.12 write path / R6-3.4). + pub fn put( + &self, + server_id: &str, + track_id: &str, + fact: &FactInputDto, + now: i64, + ) -> Result<(), String> { + self.store + .with_conn("fact.put", |conn| { + conn.execute( + UPSERT_FACT, + params![ + server_id, + track_id, + fact.fact_kind, + fact.value_real, + fact.value_int, + fact.value_text, + fact.unit, + fact.source_kind, + fact.source_id, + fact.confidence, + fact.content_hash, + now, + fact.expires_at, + ], + )?; + if fact.fact_kind == "bpm" && fact.source_kind == "user" { + if let Some(bpm) = fact.value_int { + conn.execute( + "UPDATE track SET bpm = ?3 WHERE server_id = ?1 AND id = ?2", + params![server_id, track_id, bpm], + )?; + } + } + Ok(()) + }) + .map_err(|e| e.to_string()) + } +} + +fn row_to_fact_dto(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackFactDto { + server_id: row.get(0)?, + track_id: row.get(1)?, + fact_kind: row.get(2)?, + value_real: row.get(3)?, + value_int: row.get(4)?, + value_text: row.get(5)?, + unit: row.get(6)?, + source_kind: row.get(7)?, + source_id: row.get(8)?, + confidence: row.get(9)?, + content_hash: row.get(10)?, + fetched_at: row.get(11)?, + expires_at: row.get(12)?, + }) +} + +const SELECT_FACTS_BASE: &str = "SELECT server_id, track_id, fact_kind, value_real, value_int, \ + value_text, unit, source_kind, source_id, confidence, content_hash, fetched_at, expires_at \ + FROM track_fact WHERE server_id = ?1 AND track_id = ?2"; + +const SELECT_FACTS: &str = "SELECT server_id, track_id, fact_kind, value_real, value_int, \ + value_text, unit, source_kind, source_id, confidence, content_hash, fetched_at, expires_at \ + FROM track_fact WHERE server_id = ?1 AND track_id = ?2 \ + ORDER BY fact_kind ASC, fetched_at DESC"; + +const UPSERT_FACT: &str = "INSERT INTO track_fact \ + (server_id, track_id, fact_kind, value_real, value_int, value_text, unit, \ + source_kind, source_id, source_detail, confidence, content_hash, fetched_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, ?12, ?13) \ + ON CONFLICT(server_id, track_id, fact_kind, source_kind, source_id) DO UPDATE SET \ + value_real = excluded.value_real, \ + value_int = excluded.value_int, \ + value_text = excluded.value_text, \ + unit = excluded.unit, \ + confidence = excluded.confidence, \ + content_hash = excluded.content_hash, \ + fetched_at = excluded.fetched_at, \ + expires_at = excluded.expires_at"; + +#[cfg(test)] +mod tests { + use super::*; + + fn fact(kind: &str, source_kind: &str, value_int: Option, expires_at: Option) -> FactInputDto { + FactInputDto { + fact_kind: kind.into(), + value_real: None, + value_int, + value_text: None, + unit: None, + source_kind: source_kind.into(), + source_id: "seed".into(), + confidence: 1.0, + content_hash: None, + expires_at, + } + } + + fn seed_track(store: &LibraryStore, server: &str, id: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track (server_id, id, title, synced_at, raw_json) \ + VALUES (?1, ?2, 'T', 1, '{}')", + params![server, id], + ) + }) + .unwrap(); + } + + #[test] + fn put_then_get_roundtrips() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 100).unwrap(); + let facts = repo.get("s1", "t1", &[], 200).unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_kind, "bpm"); + assert_eq!(facts[0].value_int, Some(120)); + } + + #[test] + fn get_lazily_deletes_expired_facts() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + // expires at t=150 + repo.put("s1", "t1", &fact("energy", "external_api", Some(5), Some(150)), 100).unwrap(); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 100).unwrap(); + + // read at t=200 → energy expired, dropped + excluded; bpm survives. + let facts = repo.get("s1", "t1", &[], 200).unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_kind, "bpm"); + + // and it was actually deleted from the table (not just filtered). + let total: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_fact", [], |r| r.get(0))) + .unwrap(); + assert_eq!(total, 1); + } + + #[test] + fn get_filters_by_kind() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 1).unwrap(); + repo.put("s1", "t1", &fact("energy", "analysis", Some(7), None), 1).unwrap(); + let facts = repo.get("s1", "t1", &["bpm".into()], 2).unwrap(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].fact_kind, "bpm"); + } + + #[test] + fn user_bpm_fact_also_writes_hot_track_column() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "user", Some(128), None), 1).unwrap(); + let bpm: Option = store + .with_conn("misc", |c| { + c.query_row("SELECT bpm FROM track WHERE server_id='s1' AND id='t1'", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!(bpm, Some(128), "user bpm override must write track.bpm"); + } + + #[test] + fn analysis_bpm_fact_does_not_touch_hot_track_column() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(99), None), 1).unwrap(); + let bpm: Option = store + .with_conn("misc", |c| { + c.query_row("SELECT bpm FROM track WHERE server_id='s1' AND id='t1'", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!(bpm, None, "only a user override writes the hot column (§5.12)"); + } + + #[test] + fn put_is_idempotent_on_same_source_key() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1"); + let repo = FactRepository::new(&store); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 1).unwrap(); + repo.put("s1", "t1", &fact("bpm", "analysis", Some(124), None), 2).unwrap(); + let facts = repo.get("s1", "t1", &[], 3).unwrap(); + assert_eq!(facts.len(), 1, "same (kind, source) updates in place"); + assert_eq!(facts[0].value_int, Some(124)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/mod.rs b/src-tauri/crates/psysonic-library/src/repos/mod.rs new file mode 100644 index 00000000..383edded --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/mod.rs @@ -0,0 +1,15 @@ +pub mod artifact; +pub mod fact; +pub mod sync_state; +pub mod track; +pub mod track_id_history; + +pub use artifact::ArtifactRepository; +pub use fact::FactRepository; +pub use sync_state::SyncStateRepository; +pub use track::{RemapEntry, RemapStats, TrackRepository, TrackRow}; +pub use track_id_history::TrackIdHistoryRepository; + +// Shared row-mapper + column list so the Advanced Search builder can project +// the same hot columns as the repositories without re-declaring them. +pub(crate) use track::{row_to_track_row, track_columns}; diff --git a/src-tauri/crates/psysonic-library/src/repos/sync_state.rs b/src-tauri/crates/psysonic-library/src/repos/sync_state.rs new file mode 100644 index 00000000..7e9dbedf --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/sync_state.rs @@ -0,0 +1,826 @@ +use rusqlite::{params, OptionalExtension}; +use serde_json::Value; + +use crate::store::LibraryStore; + +/// Repository over the `sync_state` row identified by `(server_id, library_scope)`. +/// PR-1b exposes just enough of the row to drive resumable initial sync — the +/// orchestrator-side helpers (poll stats, phase transitions, …) land with +/// PR-3 when there's actual sync code to consume them. +pub struct SyncStateRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> SyncStateRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Read-only queries — must not take the write mutex (ingest holds it for + /// long stretches during IS-3). + fn read( + &self, + f: impl FnOnce(&rusqlite::Connection) -> rusqlite::Result, + ) -> Result { + self.store.with_read_conn(f) + } + + /// Insert a default-valued row for this `(server_id, library_scope)` pair + /// if none exists. All non-PK columns fall back to their schema DEFAULTs + /// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …). + pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)", + params![server_id, library_scope], + )?; + Ok(()) + }) + } + + /// Read `initial_sync_cursor_json`. Returns `None` when the row doesn't + /// exist yet, `Some(Value)` otherwise (the schema DEFAULT is `'{}'`, so + /// a freshly-ensured row reads back as `Some(Object({}))`). + pub fn get_initial_sync_cursor( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT initial_sync_cursor_json FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + match raw { + None => Ok(None), + Some(s) => serde_json::from_str(&s) + .map(Some) + .map_err(|e| format!("invalid initial_sync_cursor_json: {e}")), + } + } + + /// Write `initial_sync_cursor_json`. Creates the row if needed; only the + /// cursor column is touched, all other columns keep their current values + /// (or their DEFAULTs on first insert). + pub fn set_initial_sync_cursor( + &self, + server_id: &str, + library_scope: &str, + cursor: &Value, + ) -> Result<(), String> { + let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?; + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + initial_sync_cursor_json = excluded.initial_sync_cursor_json", + params![server_id, library_scope, json], + )?; + Ok(()) + }) + } + + /// Single write-lock acquisition for cursor + local count during ingest. + pub fn set_initial_sync_cursor_and_local_track_count( + &self, + server_id: &str, + library_scope: &str, + cursor: &Value, + local_track_count: i64, + ) -> Result<(), String> { + let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?; + self.store.with_conn("sync_state.persist_cursor", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json, local_track_count) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + initial_sync_cursor_json = excluded.initial_sync_cursor_json, \ + local_track_count = excluded.local_track_count", + params![server_id, library_scope, json, local_track_count], + )?; + Ok(()) + }) + } + + /// Read `capability_flags` (spec §6.1.1). Returns `None` when the + /// row doesn't exist; SQL DEFAULT is 0 so a freshly-ensured row + /// reads back as `Some(0)`. + pub fn get_capability_flags( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT capability_flags FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + Ok(raw.map(|v| v.max(0) as u32)) + } + + /// Write `capability_flags`. Upsert scoped to that one column. + pub fn set_capability_flags( + &self, + server_id: &str, + library_scope: &str, + flags: u32, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, capability_flags) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + capability_flags = excluded.capability_flags", + params![server_id, library_scope, flags as i64], + )?; + Ok(()) + }) + } + + /// Read `sync_phase` (state-machine values per spec §6.2: + /// `idle` / `probing` / `initial_sync` / `ready` / `error`). + /// Returns `None` when the row doesn't exist; SQL DEFAULT is + /// `'idle'` so a freshly-ensured row reads back as `Some("idle")`. + pub fn get_sync_phase( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT sync_phase FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, String>(0), + ) + .optional() + }) + } + + /// True when a full sync has completed at least once. + pub fn has_last_full_sync_at( + &self, + server_id: &str, + library_scope: &str, + ) -> Result { + self.read(|conn| { + let ts: Option> = conn + .query_row( + "SELECT last_full_sync_at FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional()?; + Ok(ts.flatten().is_some()) + }) + } + + /// Write `sync_phase`. Upsert scoped to that one column. + pub fn set_sync_phase( + &self, + server_id: &str, + library_scope: &str, + phase: &str, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, sync_phase) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + sync_phase = excluded.sync_phase", + params![server_id, library_scope, phase], + )?; + Ok(()) + }) + } + + /// Write `server_last_scan_iso` — server-reported timestamp of the + /// last completed scan, captured from `getScanStatus.lastScan`. + pub fn set_server_last_scan_iso( + &self, + server_id: &str, + library_scope: &str, + last_scan_iso: Option<&str>, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + server_last_scan_iso = excluded.server_last_scan_iso", + params![server_id, library_scope, last_scan_iso], + )?; + Ok(()) + }) + } + + /// Write `indexes_last_modified_ms` — watermark for the file-tree + /// browse path (`getIndexes.lastModified`). + pub fn set_indexes_last_modified_ms( + &self, + server_id: &str, + library_scope: &str, + last_modified_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + indexes_last_modified_ms = excluded.indexes_last_modified_ms", + params![server_id, library_scope, last_modified_ms], + )?; + Ok(()) + }) + } + + /// Read `artists_last_modified_ms`. Returns `None` when the row + /// doesn't exist or the column is `NULL`. DS-2 in §6.4 compares + /// the live `getArtists.lastModified` against this to decide + /// whether a delta pass is needed. + pub fn get_artists_last_modified_ms( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT artists_last_modified_ms FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + /// Read `server_last_scan_iso`. Returns `None` when row missing + /// or column null. DS-2 uses this against `getScanStatus.lastScan` + /// for the Huge-tier short-circuit. + pub fn get_server_last_scan_iso( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT server_last_scan_iso FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + /// Read `library_tier`. Returns `None` when row missing. DS-0 + /// picks between `getArtists` (small/medium) and `getScanStatus` + /// (huge) based on this. + pub fn get_library_tier( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT library_tier FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, String>(0), + ) + .optional() + }) + } + + /// Read `next_poll_at` — epoch ms scheduling target. `None` when + /// the row is missing or the column is `NULL` (no schedule yet). + pub fn get_next_poll_at( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT next_poll_at FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + /// Write `next_poll_at`. Upsert scoped to that one column. + pub fn set_next_poll_at( + &self, + server_id: &str, + library_scope: &str, + epoch_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, next_poll_at) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + next_poll_at = excluded.next_poll_at", + params![server_id, library_scope, epoch_ms], + )?; + Ok(()) + }) + } + + /// Read `poll_stats_json`. Returns `Some(Value)` for an existing + /// row (SQL DEFAULT is `'{}'`, so a freshly-ensured row reads + /// back as `Some(Object({}))`), `None` when the row is absent. + pub fn get_poll_stats_json( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT poll_stats_json FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + match raw { + None => Ok(None), + Some(s) => serde_json::from_str(&s) + .map(Some) + .map_err(|e| format!("invalid poll_stats_json: {e}")), + } + } + + /// Write `poll_stats_json`. Upsert scoped to that one column. + pub fn set_poll_stats_json( + &self, + server_id: &str, + library_scope: &str, + stats: &Value, + ) -> Result<(), String> { + let json = serde_json::to_string(stats).map_err(|e| e.to_string())?; + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + poll_stats_json = excluded.poll_stats_json", + params![server_id, library_scope, json], + )?; + Ok(()) + }) + } + + /// Read `local_track_count` snapshot (counts kept in sync by C8 / + /// PR-3d2 scheduler ticks). Returns `None` when unset. + pub fn get_local_track_count( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT local_track_count FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + pub fn set_local_track_count( + &self, + server_id: &str, + library_scope: &str, + count: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, local_track_count) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + local_track_count = excluded.local_track_count", + params![server_id, library_scope, count], + )?; + Ok(()) + }) + } + + pub fn get_server_track_count( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + self.read(|conn| { + conn.query_row( + "SELECT server_track_count FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get::<_, Option>(0), + ) + .optional() + }) + .map(|opt| opt.flatten()) + } + + pub fn set_server_track_count( + &self, + server_id: &str, + library_scope: &str, + count: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, server_track_count) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + server_track_count = excluded.server_track_count", + params![server_id, library_scope, count], + )?; + Ok(()) + }) + } + + /// Read `n1_bulk_unreliable` — per-server learned flag (R7-15). When + /// set, the strategy selector stops choosing N1 for this server (the + /// native `/api/song` endpoint 500'd beyond a deep offset). Returns + /// `None` when the row doesn't exist; SQL DEFAULT is 0 so a + /// freshly-ensured row reads back as `Some(false)`. + pub fn get_n1_bulk_unreliable( + &self, + server_id: &str, + library_scope: &str, + ) -> Result, String> { + let raw: Option = self.read(|conn| { + conn.query_row( + "SELECT n1_bulk_unreliable FROM sync_state \ + WHERE server_id = ?1 AND library_scope = ?2", + params![server_id, library_scope], + |row| row.get(0), + ) + .optional() + })?; + Ok(raw.map(|v| v != 0)) + } + + /// Write `n1_bulk_unreliable`. Upsert scoped to that one column. + pub fn set_n1_bulk_unreliable( + &self, + server_id: &str, + library_scope: &str, + unreliable: bool, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + n1_bulk_unreliable = excluded.n1_bulk_unreliable", + params![server_id, library_scope, unreliable as i64], + )?; + Ok(()) + }) + } + + /// Stamp `last_full_sync_at = now` (epoch ms). Called by IS-6 when + /// the initial full ingest completes successfully. + pub fn set_last_full_sync_at( + &self, + server_id: &str, + library_scope: &str, + epoch_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + last_full_sync_at = excluded.last_full_sync_at", + params![server_id, library_scope, epoch_ms], + )?; + Ok(()) + }) + } + + /// Stamp `last_delta_sync_at = now` (epoch ms). Called by DS-9 at + /// the end of every successful delta pass. + pub fn set_last_delta_sync_at( + &self, + server_id: &str, + library_scope: &str, + epoch_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + last_delta_sync_at = excluded.last_delta_sync_at", + params![server_id, library_scope, epoch_ms], + )?; + Ok(()) + }) + } + + /// Write `artists_last_modified_ms` — watermark for the ID3 path + /// (`getArtists.lastModified`); §2.2.1 background poll keys off + /// this. + pub fn set_artists_last_modified_ms( + &self, + server_id: &str, + library_scope: &str, + last_modified_ms: i64, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + artists_last_modified_ms = excluded.artists_last_modified_ms", + params![server_id, library_scope, last_modified_ms], + )?; + Ok(()) + }) + } + + /// Write `library_tier` (spec §6.2.2 — `small` / `medium` / `huge` + /// / `unknown`). Drives the adaptive poll interval; PR-3d wires + /// the EWMA loop that picks this. + pub fn set_library_tier( + &self, + server_id: &str, + library_scope: &str, + tier: &str, + ) -> Result<(), String> { + self.store.with_conn("misc", |conn| { + conn.execute( + "INSERT INTO sync_state (server_id, library_scope, library_tier) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(server_id, library_scope) DO UPDATE SET \ + library_tier = excluded.library_tier", + params![server_id, library_scope, tier], + )?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn ensure_creates_row_with_default_cursor() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + + let cursor = repo.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(cursor, Some(json!({})), "DEFAULT must read back as empty object"); + } + + #[test] + fn ensure_is_idempotent() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + repo.ensure("s1", "").unwrap(); + + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM sync_state", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn get_returns_none_for_missing_row() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + assert_eq!(repo.get_initial_sync_cursor("absent", "").unwrap(), None); + } + + #[test] + fn set_roundtrips_nested_cursor_value() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + let cursor = json!({ + "phase": "ingest_tracks", + "offset": 12_500, + "last_seen_id": "tr_abc", + "filters": { "library_id": "lib-1" }, + }); + repo.set_initial_sync_cursor("s1", "", &cursor).unwrap(); + let got = repo.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(got, Some(cursor)); + } + + #[test] + fn set_overwrites_prior_cursor() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 1})).unwrap(); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 2})).unwrap(); + let got = repo.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(got, Some(json!({"offset": 2}))); + } + + #[test] + fn set_preserves_other_columns_on_upsert() { + // The ON CONFLICT clause must only touch the cursor column. Other + // DEFAULT-backed fields stay at their initial values across upserts. + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"x": 1})).unwrap(); + + // Mutate a sibling column out-of-band to detect any accidental reset. + store + .with_conn("misc", |c| { + c.execute( + "UPDATE sync_state SET sync_phase = 'ingesting' WHERE server_id = 's1'", + [], + ) + }) + .unwrap(); + + // Second cursor write must not touch sync_phase. + repo.set_initial_sync_cursor("s1", "", &json!({"x": 2})).unwrap(); + let phase: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT sync_phase FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(phase, "ingesting"); + } + + #[test] + fn library_scope_separates_rows_per_server() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"all": true})).unwrap(); + repo.set_initial_sync_cursor("s1", "lib-1", &json!({"lib": "one"})).unwrap(); + + assert_eq!( + repo.get_initial_sync_cursor("s1", "").unwrap(), + Some(json!({"all": true})) + ); + assert_eq!( + repo.get_initial_sync_cursor("s1", "lib-1").unwrap(), + Some(json!({"lib": "one"})) + ); + } + + // ── PR-3a: capability_flags, sync_phase, watermarks, library_tier ──── + + #[test] + fn capability_flags_roundtrip() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + assert_eq!(repo.get_capability_flags("s1", "").unwrap(), Some(0)); + repo.set_capability_flags("s1", "", 0x002 | 0x010).unwrap(); + assert_eq!(repo.get_capability_flags("s1", "").unwrap(), Some(0x012)); + } + + #[test] + fn capability_flags_returns_none_for_missing_row() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + assert_eq!(repo.get_capability_flags("absent", "").unwrap(), None); + } + + #[test] + fn capability_flags_set_creates_row_with_other_defaults() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_capability_flags("s1", "", 0x008).unwrap(); + // sync_phase defaulted to 'idle' on the implicit insert. + assert_eq!(repo.get_sync_phase("s1", "").unwrap().as_deref(), Some("idle")); + } + + #[test] + fn sync_phase_default_is_idle_after_ensure() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + assert_eq!(repo.get_sync_phase("s1", "").unwrap().as_deref(), Some("idle")); + } + + #[test] + fn sync_phase_transitions_through_state_machine_values() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + for phase in ["probing", "initial_sync", "ready", "error", "idle"] { + repo.set_sync_phase("s1", "", phase).unwrap(); + assert_eq!( + repo.get_sync_phase("s1", "").unwrap().as_deref(), + Some(phase) + ); + } + } + + #[test] + fn watermark_setters_preserve_each_other() { + // Each setter must scope its `ON CONFLICT … DO UPDATE` to its own + // column. Set three and read all three back unchanged. + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_server_last_scan_iso("s1", "", Some("2026-05-01T12:00:00Z")).unwrap(); + repo.set_indexes_last_modified_ms("s1", "", 1_700_000_000_000).unwrap(); + repo.set_artists_last_modified_ms("s1", "", 1_700_000_500_000).unwrap(); + + let (iso, idx_ms, art_ms): (Option, Option, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT server_last_scan_iso, indexes_last_modified_ms, artists_last_modified_ms \ + FROM sync_state WHERE server_id = 's1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + }) + .unwrap(); + assert_eq!(iso.as_deref(), Some("2026-05-01T12:00:00Z")); + assert_eq!(idx_ms, Some(1_700_000_000_000)); + assert_eq!(art_ms, Some(1_700_000_500_000)); + } + + #[test] + fn library_tier_roundtrip() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_library_tier("s1", "", "huge").unwrap(); + let tier: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT library_tier FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(tier, "huge"); + } + + #[test] + fn n1_bulk_unreliable_defaults_false_and_roundtrips() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.ensure("s1", "").unwrap(); + // DEFAULT 0 reads back as Some(false); existing servers stay N1-eligible. + assert_eq!(repo.get_n1_bulk_unreliable("s1", "").unwrap(), Some(false)); + assert_eq!(repo.get_n1_bulk_unreliable("absent", "").unwrap(), None); + + repo.set_n1_bulk_unreliable("s1", "", true).unwrap(); + assert_eq!(repo.get_n1_bulk_unreliable("s1", "").unwrap(), Some(true)); + repo.set_n1_bulk_unreliable("s1", "", false).unwrap(); + assert_eq!(repo.get_n1_bulk_unreliable("s1", "").unwrap(), Some(false)); + } + + #[test] + fn n1_bulk_unreliable_set_does_not_clobber_cursor() { + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 7})).unwrap(); + repo.set_n1_bulk_unreliable("s1", "", true).unwrap(); + assert_eq!( + repo.get_initial_sync_cursor("s1", "").unwrap(), + Some(json!({"offset": 7})) + ); + } + + #[test] + fn capability_flags_set_does_not_clobber_cursor() { + // Cross-check: setting flags must not reset + // `initial_sync_cursor_json` back to '{}'. + let store = LibraryStore::open_in_memory(); + let repo = SyncStateRepository::new(&store); + repo.set_initial_sync_cursor("s1", "", &json!({"offset": 42})).unwrap(); + repo.set_capability_flags("s1", "", 0x002).unwrap(); + assert_eq!( + repo.get_initial_sync_cursor("s1", "").unwrap(), + Some(json!({"offset": 42})) + ); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs new file mode 100644 index 00000000..b1889b07 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -0,0 +1,980 @@ +use rusqlite::{params, OptionalExtension}; + +use crate::store::{LibraryStore, WriteOpTiming}; + +/// One row of the `track` table — every hot column from spec §5.1 plus +/// `raw_json` (the full normalized SubsonicSong). Sync code (PR-2/PR-3) is +/// expected to project ingested payloads into this shape, not to talk SQL +/// directly. +#[derive(Debug, Clone)] +pub struct TrackRow { + pub server_id: String, + pub id: String, + pub title: String, + pub title_sort: Option, + pub artist: Option, + pub artist_id: Option, + pub album: String, + pub album_id: Option, + pub album_artist: Option, + pub duration_sec: i64, + pub track_number: Option, + pub disc_number: Option, + pub year: Option, + pub genre: Option, + pub suffix: Option, + pub bit_rate: Option, + pub size_bytes: Option, + pub cover_art_id: Option, + pub starred_at: Option, + pub user_rating: Option, + pub play_count: Option, + pub played_at: Option, + pub server_path: Option, + pub library_id: Option, + pub isrc: Option, + pub mbid_recording: Option, + pub bpm: Option, + pub replay_gain_track_db: Option, + pub replay_gain_album_db: Option, + pub content_hash: Option, + pub server_updated_at: Option, + pub server_created_at: Option, + pub deleted: bool, + pub synced_at: i64, + pub raw_json: String, +} + +/// One detected remap during an upsert batch. Sync code can use this +/// to emit `library:tracks-changed { remapped: [{from, to}] }` (spec +/// §6.9) so the UI can refresh open per-track views. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemapEntry { + pub server_id: String, + pub old_id: String, + pub new_id: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RemapStats { + pub remapped: Vec, +} + +pub struct TrackRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> TrackRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Batch upsert without remap detection. Suitable for generic + /// Subsonic servers where `UnstableTrackIds` is clear (track ids + /// are stable across reindexing). Wrapped in a single transaction. + pub fn upsert_batch(&self, rows: &[TrackRow]) -> Result<(), String> { + self.upsert_batch_with_remap(rows, false).map(|_| ()) + } + + /// IS-3 initial-sync fast path: upsert rows only. Skips §6.9 remap + /// detection and inline canonical linking — both run on delta sync + /// or in a post-ingest canonical pass so 500-row batches stay fast. + pub fn upsert_batch_initial_ingest(&self, rows: &[TrackRow]) -> Result<(), String> { + self.upsert_batch_initial_ingest_timed(rows).map(|_| ()) + } + + pub fn upsert_batch_initial_ingest_timed( + &self, + rows: &[TrackRow], + ) -> Result { + if rows.is_empty() { + return Ok(WriteOpTiming::default()); + } + let (_, timing) = self.store.with_conn_mut_timed("track.upsert_initial_ingest", |conn| { + let tx = conn.transaction()?; + let mut upsert = tx.prepare_cached(UPSERT_SQL)?; + for r in rows { + upsert.execute(params![ + r.server_id, + r.id, + r.title, + r.title_sort, + r.artist, + r.artist_id, + r.album, + r.album_id, + r.album_artist, + r.duration_sec, + r.track_number, + r.disc_number, + r.year, + r.genre, + r.suffix, + r.bit_rate, + r.size_bytes, + r.cover_art_id, + r.starred_at, + r.user_rating, + r.play_count, + r.played_at, + r.server_path, + r.library_id, + r.isrc, + r.mbid_recording, + r.bpm, + r.replay_gain_track_db, + r.replay_gain_album_db, + r.content_hash, + r.server_updated_at, + r.server_created_at, + if r.deleted { 1_i64 } else { 0 }, + r.synced_at, + r.raw_json, + ])?; + } + drop(upsert); + tx.commit()?; + Ok(()) + })?; + Ok(timing) + } + + /// SELECT a single track by `(server_id, id)`. Returns `None` + /// when missing or deleted (`deleted = 1`). Used by + /// `library_get_track` and the offline-path command. + pub fn find_one( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACK_BY_ID)?; + stmt.query_row(params![server_id, track_id], row_to_track_row) + .optional() + }) + } + + /// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs + /// preserve their order in the result; unknown / deleted refs + /// are silently dropped (frontend reads `tracks.length` against + /// `refs.length` to detect partial responses). + pub fn find_batch( + &self, + refs: &[(String, String)], + ) -> Result, String> { + if refs.is_empty() { + return Ok(Vec::new()); + } + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACK_BY_ID)?; + let mut out: Vec = Vec::with_capacity(refs.len()); + for (server_id, track_id) in refs { + if let Some(row) = stmt + .query_row(params![server_id, track_id], row_to_track_row) + .optional()? + { + out.push(row); + } + } + Ok(out) + }) + } + + /// SELECT every non-deleted track on this album, ordered by + /// `disc_number ASC, track_number ASC` for stable display. + pub fn find_by_album( + &self, + server_id: &str, + album_id: &str, + ) -> Result, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACKS_BY_ALBUM)?; + let rows: rusqlite::Result> = stmt + .query_map(params![server_id, album_id], row_to_track_row)? + .collect(); + rows + }) + } + + /// Batch upsert with optional §6.9 id-remap detection. When + /// `unstable_track_ids` is `true`, each incoming row is checked + /// against the existing `track` table for a collision via + /// `content_hash` or `server_path` carrying a different id. On + /// collision, child tables (`track_offline` and the FK-bound + /// extension / fact / artifact / canonical_link tables) are + /// retargeted onto the new id, a `track_id_history` row is + /// recorded, and the old `track` row is deleted — all inside the + /// same SQLite transaction so partial remaps can't leak. + pub fn upsert_batch_with_remap( + &self, + rows: &[TrackRow], + unstable_track_ids: bool, + ) -> Result { + if rows.is_empty() { + return Ok(RemapStats::default()); + } + self.store.with_conn_mut("misc", |conn| { + let tx = conn.transaction()?; + let mut remapped: Vec = Vec::new(); + let mut upsert = tx.prepare_cached(UPSERT_SQL)?; + let mut remap_lookup = if unstable_track_ids { + Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?) + } else { + None + }; + + for r in rows { + // Spec §6.9: detect collision BEFORE the upsert so the + // old id is known. The upsert itself comes next; only + // then do we retarget children to the new id, since + // child tables FK→track(server_id, id) and would refuse + // an UPDATE pointing at an id that doesn't exist yet. + let detected_old: Option = if let Some(ref mut lookup) = remap_lookup { + detect_remap_target_cached(lookup, r)? + } else { + None + }; + + upsert.execute(params![ + r.server_id, + r.id, + r.title, + r.title_sort, + r.artist, + r.artist_id, + r.album, + r.album_id, + r.album_artist, + r.duration_sec, + r.track_number, + r.disc_number, + r.year, + r.genre, + r.suffix, + r.bit_rate, + r.size_bytes, + r.cover_art_id, + r.starred_at, + r.user_rating, + r.play_count, + r.played_at, + r.server_path, + r.library_id, + r.isrc, + r.mbid_recording, + r.bpm, + r.replay_gain_track_db, + r.replay_gain_album_db, + r.content_hash, + r.server_updated_at, + r.server_created_at, + if r.deleted { 1_i64 } else { 0 }, + r.synced_at, + r.raw_json, + ])?; + + if let Some(old_id) = detected_old { + remap_existing_to_new( + &tx, + &r.server_id, + &old_id, + &r.id, + r.content_hash.as_deref(), + r.server_path.as_deref(), + r.synced_at, + )?; + remapped.push(RemapEntry { + server_id: r.server_id.clone(), + old_id, + new_id: r.id.clone(), + }); + } + + // H2 (§5.5A): link this track to its canonical id by its + // strong key (ISRC, else MBID recording). Inline + O(1); + // a no-op for tracks that carry neither. + crate::canonical::link_track( + &tx, + &r.server_id, + &r.id, + r.isrc.as_deref(), + r.mbid_recording.as_deref(), + r.synced_at, + )?; + } + + drop(upsert); + drop(remap_lookup); + + tx.commit()?; + Ok(RemapStats { remapped }) + }) + } +} + +const REMAP_LOOKUP_SQL: &str = r#" +SELECT id FROM track + WHERE server_id = ?1 + AND deleted = 0 + AND id != ?2 + AND ( + (?3 IS NOT NULL AND content_hash = ?3) + OR (?4 IS NOT NULL AND server_path = ?4) + ) + LIMIT 1 +"#; + +/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a +/// non-deleted row with a different id on this server matches the +/// incoming row's `content_hash` or `server_path`. +fn detect_remap_target_cached( + lookup: &mut rusqlite::Statement<'_>, + incoming: &TrackRow, +) -> rusqlite::Result> { + // Empty-string sentinels are *not* eligible — spec §6.9 explicitly + // excludes them so the file-tree default never collides. + let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty()); + let path = incoming.server_path.as_deref().filter(|s| !s.is_empty()); + if hash.is_none() && path.is_none() { + return Ok(None); + } + lookup + .query_row( + params![incoming.server_id, incoming.id, hash, path], + |row| row.get::<_, String>(0), + ) + .optional() +} + +/// Run the §6.9 retarget half — UPDATE every FK-bound child to the +/// new id, INSERT into `track_id_history`, DELETE the old `track` row. +/// `track_offline` has no FK to `track` (spec §5.14) but still needs +/// its row retargeted so the cached file resolves under the new id. +fn remap_existing_to_new( + tx: &rusqlite::Transaction<'_>, + server_id: &str, + old_id: &str, + new_id: &str, + content_hash: Option<&str>, + server_path: Option<&str>, + remapped_at: i64, +) -> rusqlite::Result<()> { + for table in [ + "track_offline", + "track_extension", + "track_fact", + "track_artifact", + "track_canonical_link", + ] { + tx.execute( + &format!( + "UPDATE {table} SET track_id = ?1 \ + WHERE server_id = ?2 AND track_id = ?3" + ), + params![new_id, server_id, old_id], + )?; + } + tx.execute( + "INSERT INTO track_id_history \ + (server_id, old_id, new_id, content_hash, server_path, remapped_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(server_id, old_id) DO UPDATE SET \ + new_id = excluded.new_id, \ + content_hash = excluded.content_hash, \ + server_path = excluded.server_path, \ + remapped_at = excluded.remapped_at", + params![server_id, old_id, new_id, content_hash, server_path, remapped_at], + )?; + tx.execute( + "DELETE FROM track WHERE server_id = ?1 AND id = ?2", + params![server_id, old_id], + )?; + Ok(()) +} + +/// Column list mirroring the `track` schema (§5.1) — used by every +/// `SELECT … FROM track` so the row-mapper can index by position. +const TRACK_COLUMNS: &str = "\ + server_id, id, title, title_sort, artist, artist_id, album, album_id, \ + album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, \ + played_at, server_path, library_id, isrc, mbid_recording, bpm, \ + replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, \ + server_created_at, deleted, synced_at, raw_json"; + +const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ + album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ + server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \ + content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ + FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0"; + +const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ + album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ + server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \ + content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ + FROM track WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 \ + ORDER BY disc_number ASC NULLS LAST, track_number ASC NULLS LAST, id ASC"; + +pub(crate) fn track_columns() -> &'static str { + TRACK_COLUMNS +} + +pub(crate) fn row_to_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackRow { + server_id: row.get(0)?, + id: row.get(1)?, + title: row.get(2)?, + title_sort: row.get(3)?, + artist: row.get(4)?, + artist_id: row.get(5)?, + album: row.get(6)?, + album_id: row.get(7)?, + album_artist: row.get(8)?, + duration_sec: row.get(9)?, + track_number: row.get(10)?, + disc_number: row.get(11)?, + year: row.get(12)?, + genre: row.get(13)?, + suffix: row.get(14)?, + bit_rate: row.get(15)?, + size_bytes: row.get(16)?, + cover_art_id: row.get(17)?, + starred_at: row.get(18)?, + user_rating: row.get(19)?, + play_count: row.get(20)?, + played_at: row.get(21)?, + server_path: row.get(22)?, + library_id: row.get(23)?, + isrc: row.get(24)?, + mbid_recording: row.get(25)?, + bpm: row.get(26)?, + replay_gain_track_db: row.get(27)?, + replay_gain_album_db: row.get(28)?, + content_hash: row.get(29)?, + server_updated_at: row.get(30)?, + server_created_at: row.get(31)?, + deleted: row.get::<_, i64>(32)? != 0, + synced_at: row.get(33)?, + raw_json: row.get(34)?, + }) +} + +const UPSERT_SQL: &str = r#" +INSERT INTO track ( + server_id, id, title, title_sort, artist, artist_id, album, album_id, + album_artist, duration_sec, track_number, disc_number, year, genre, suffix, + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, + played_at, server_path, library_id, isrc, mbid_recording, bpm, + replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, + server_created_at, deleted, synced_at, raw_json +) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, + ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, + ?33, ?34, ?35 +) +ON CONFLICT(server_id, id) DO UPDATE SET + title = excluded.title, + title_sort = excluded.title_sort, + artist = excluded.artist, + artist_id = excluded.artist_id, + album = excluded.album, + album_id = excluded.album_id, + album_artist = excluded.album_artist, + duration_sec = excluded.duration_sec, + track_number = excluded.track_number, + disc_number = excluded.disc_number, + year = excluded.year, + genre = excluded.genre, + suffix = excluded.suffix, + bit_rate = excluded.bit_rate, + size_bytes = excluded.size_bytes, + cover_art_id = excluded.cover_art_id, + starred_at = excluded.starred_at, + user_rating = excluded.user_rating, + play_count = excluded.play_count, + played_at = excluded.played_at, + server_path = excluded.server_path, + library_id = excluded.library_id, + isrc = excluded.isrc, + mbid_recording = excluded.mbid_recording, + bpm = excluded.bpm, + replay_gain_track_db = excluded.replay_gain_track_db, + replay_gain_album_db = excluded.replay_gain_album_db, + -- E2: never let a sync (which passes NULL content_hash) clobber the + -- playback-derived md5_16kb written via library_patch_track / the analysis + -- bridge. A non-empty incoming hash still wins. + content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash), + server_updated_at = excluded.server_updated_at, + server_created_at = excluded.server_created_at, + deleted = excluded.deleted, + synced_at = excluded.synced_at, + raw_json = excluded.raw_json +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn row(server: &str, id: &str, title: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some("The Artist".into()), + artist_id: Some("ar1".into()), + album: "An Album".into(), + album_id: Some("al1".into()), + album_artist: Some("The Artist".into()), + duration_sec: 240, + track_number: Some(3), + disc_number: Some(1), + year: Some(2024), + genre: Some("Ambient".into()), + suffix: Some("flac".into()), + bit_rate: Some(1000), + size_bytes: Some(32_000_000), + cover_art_id: Some("cv1".into()), + starred_at: None, + user_rating: None, + play_count: Some(0), + played_at: None, + server_path: Some("Artist/Album/03.flac".into()), + library_id: Some("lib-1".into()), + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: Some("hash-abc".into()), + server_updated_at: Some(1_700_000_000), + server_created_at: Some(1_699_000_000), + deleted: false, + synced_at: 1_700_000_500, + raw_json: r#"{"id":"t1"}"#.into(), + } + } + + #[test] + fn resync_does_not_clobber_playback_content_hash() { + // E2 safety property: a sync (which passes content_hash = None) must + // never wipe the playback-derived md5 written via patch / the bridge. + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + + let mut initial = row("s1", "t1", "First"); + initial.content_hash = None; + repo.upsert_batch(&[initial]).unwrap(); + + // Playback records the content fingerprint. + store + .with_conn("misc", |c| { + c.execute( + "UPDATE track SET content_hash = 'playback-md5' WHERE server_id='s1' AND id='t1'", + [], + ) + }) + .unwrap(); + + let read = |store: &LibraryStore| -> Option { + store + .with_conn("misc", |c| { + c.query_row( + "SELECT content_hash FROM track WHERE server_id='s1' AND id='t1'", + [], + |r| r.get(0), + ) + }) + .unwrap() + }; + + // Resync with a NULL hash preserves the playback value. + let mut resync = row("s1", "t1", "First (resynced)"); + resync.content_hash = None; + repo.upsert_batch(&[resync]).unwrap(); + assert_eq!(read(&store).as_deref(), Some("playback-md5")); + + // A non-empty incoming hash still wins. + let mut with_hash = row("s1", "t1", "First"); + with_hash.content_hash = Some("server-hash".into()); + repo.upsert_batch(&[with_hash]).unwrap(); + assert_eq!(read(&store).as_deref(), Some("server-hash")); + } + + #[test] + fn upsert_inserts_new_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "First"), row("s1", "t2", "Second")]) + .unwrap(); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + #[test] + fn upsert_updates_existing_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Original")]).unwrap(); + + let mut updated = row("s1", "t1", "Updated"); + updated.bpm = Some(128); + updated.starred_at = Some(1_700_000_999); + repo.upsert_batch(&[updated]).unwrap(); + + let (title, bpm, starred): (String, Option, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT title, bpm, starred_at FROM track WHERE server_id='s1' AND id='t1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + }) + .unwrap(); + assert_eq!(title, "Updated"); + assert_eq!(bpm, Some(128)); + assert_eq!(starred, Some(1_700_000_999)); + + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1, "upsert must not duplicate the row"); + } + + #[test] + fn upsert_empty_batch_is_noop() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[]).unwrap(); + } + + #[test] + fn upsert_keeps_server_scope_separate() { + // Same `id` on two different servers must produce two rows + // (PRIMARY KEY is composite). + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "From S1"), row("s2", "t1", "From S2")]) + .unwrap(); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + #[test] + fn upsert_populates_fts_via_trigger() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Aurora Boreal")]).unwrap(); + let fts_hit: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'aurora'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(fts_hit, 1); + } + + #[test] + fn upsert_update_refreshes_fts_via_trigger() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Old Title")]).unwrap(); + repo.upsert_batch(&[row("s1", "t1", "Brand New Title")]).unwrap(); + + let old_hit: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'old'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + let new_hit: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'brand'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(old_hit, 0, "delete-trigger must drop the stale FTS row"); + assert_eq!(new_hit, 1); + } + + #[test] + fn initial_ingest_batch_skips_remap_and_canonical() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let rows: Vec = (0..500) + .map(|i| { + let mut r = row("s1", &format!("t{i:04}"), &format!("Track {i:04}")); + r.server_path = Some(format!("/music/track{i:04}.flac")); + r.isrc = Some(format!("USRC{i:06}")); + r.raw_json = format!(r#"{{"id":"t{i:04}","payload":"#) + + &"x".repeat(512) + + r#""}"#; + r + }) + .collect(); + let start = std::time::Instant::now(); + repo.upsert_batch_initial_ingest(&rows).unwrap(); + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_millis(500), + "initial ingest batch(500) took {elapsed:?}" + ); + } + + #[test] + fn upsert_500_rows_completes_well_under_perf_budget() { + // Spec §5.1 / AC A3: `upsert_batch` should land 500 rows under 100ms + // typical. The CI threshold is 5× that to absorb slow runners and + // the difference between debug and release; any regression past it + // is real signal. + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let rows: Vec = (0..500) + .map(|i| row("s1", &format!("t{i:04}"), &format!("Track {i:04}"))) + .collect(); + + let start = std::time::Instant::now(); + repo.upsert_batch(&rows).unwrap(); + let elapsed = start.elapsed(); + + let stored: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(stored, 500); + + assert!( + elapsed < std::time::Duration::from_millis(500), + "upsert_batch(500 rows) took {elapsed:?}; AC A3 target is <100ms typical, \ + test fails past 5× that" + ); + } + + // ── PR-3b: §6.9 id remap detection ──────────────────────────────────── + + fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow { + let mut r = row(server, id, "Title"); + r.content_hash = if hash.is_empty() { None } else { Some(hash.into()) }; + r.server_path = if path.is_empty() { None } else { Some(path.into()) }; + r + } + + #[test] + fn remap_disabled_never_records_history_even_on_hash_collision() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "deadbeef", "")]) + .unwrap(); + + // Generic Subsonic path: caller passes `unstable_track_ids = false`. + let stats = repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "deadbeef", "")], + false, + ) + .unwrap(); + assert!(stats.remapped.is_empty()); + + let track_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + let hist_count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_id_history", [], |r| r.get(0))) + .unwrap(); + assert_eq!(track_count, 2, "both ids coexist when remap is off"); + assert_eq!(hist_count, 0); + } + + #[test] + fn remap_via_content_hash_replaces_old_row_and_records_history() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + // Seed with the old id; child tables get a row each that must + // follow the remap. + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "deadbeef", "/path/x.flac")]) + .unwrap(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO track_offline \ + (server_id, track_id, local_path, cached_at) \ + VALUES ('s1', 'tr_old', '/local/x.flac', 1)", + [], + )?; + c.execute( + "INSERT INTO track_extension \ + (server_id, track_id, kind, payload, updated_at) \ + VALUES ('s1', 'tr_old', 'user_note', X'7B7D', 1)", + [], + )?; + Ok(()) + }) + .unwrap(); + + let stats = repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "deadbeef", "/path/x.flac")], + true, + ) + .unwrap(); + assert_eq!(stats.remapped.len(), 1); + assert_eq!(stats.remapped[0].old_id, "tr_old"); + assert_eq!(stats.remapped[0].new_id, "tr_new"); + + // Old track row gone, new one in place. + let ids: Vec = store + .with_conn("misc", |c| { + let mut stmt = c.prepare("SELECT id FROM track WHERE server_id = 's1'")?; + let r: rusqlite::Result> = stmt.query_map([], |r| r.get(0))?.collect(); + r + }) + .unwrap(); + assert_eq!(ids, vec!["tr_new"]); + + // Child tables follow the new id. + let offline_id: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT track_id FROM track_offline WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(offline_id, "tr_new"); + let ext_id: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT track_id FROM track_extension WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(ext_id, "tr_new"); + + // History row recorded. + let hist = crate::repos::TrackIdHistoryRepository::new(&store); + assert_eq!( + hist.lookup_new_id("s1", "tr_old").unwrap().as_deref(), + Some("tr_new") + ); + } + + #[test] + fn remap_via_server_path_only_works_when_hash_missing() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "", "/path/y.mp3")]) + .unwrap(); + // Server only ships server_path on the new row — no hash yet. + let stats = repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "", "/path/y.mp3")], + true, + ) + .unwrap(); + assert_eq!(stats.remapped.len(), 1, "path-based remap must trigger"); + } + + #[test] + fn remap_skips_when_neither_hash_nor_path_present() { + // Defensive: empty-string sentinels must not cause spurious + // remaps across unrelated rows that happen to lack hash + path. + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_old", "", "")]).unwrap(); + let stats = repo + .upsert_batch_with_remap(&[row_with_id_hash("s1", "tr_new", "", "")], true) + .unwrap(); + assert!(stats.remapped.is_empty()); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow"); + } + + #[test] + fn remap_is_noop_when_new_id_matches_existing_id() { + // Standard delta-sync: same id, same hash. Must not trigger + // remap (SELECT excludes id = T.id). + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row_with_id_hash("s1", "tr_1", "h", "/p")]).unwrap(); + let stats = repo + .upsert_batch_with_remap(&[row_with_id_hash("s1", "tr_1", "h", "/p")], true) + .unwrap(); + assert!(stats.remapped.is_empty()); + } + + // ── H2: canonical linking on the upsert path (§5.5A) ─────────────── + + #[test] + fn upsert_links_track_to_canonical_by_isrc() { + let store = LibraryStore::open_in_memory(); + let mut r = row("s1", "t1", "Title"); + r.isrc = Some("USRC100".into()); + TrackRepository::new(&store).upsert_batch(&[r]).unwrap(); + let cid: Option = store + .with_conn("misc", |c| { + c.query_row( + "SELECT canonical_id FROM track_canonical_link \ + WHERE server_id='s1' AND track_id='t1'", + [], + |r| r.get(0), + ) + .optional() + }) + .unwrap(); + assert_eq!(cid.as_deref(), Some("isrc:USRC100")); + } + + #[test] + fn upsert_shares_canonical_across_servers_with_same_isrc() { + let store = LibraryStore::open_in_memory(); + let mut a = row("s1", "t1", "T"); + a.isrc = Some("USRC200".into()); + let mut b = row("s2", "t9", "T"); + b.isrc = Some("USRC200".into()); + TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap(); + let distinct: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(DISTINCT canonical_id) FROM track_canonical_link", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(distinct, 1, "same ISRC on two servers → one canonical id"); + } + + #[test] + fn upsert_without_strong_key_creates_no_canonical_link() { + let store = LibraryStore::open_in_memory(); + // `row(...)` leaves isrc / mbid_recording as None. + TrackRepository::new(&store) + .upsert_batch(&[row("s1", "t1", "T")]) + .unwrap(); + let count: i64 = store + .with_conn("misc", |c| { + c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/track_id_history.rs b/src-tauri/crates/psysonic-library/src/repos/track_id_history.rs new file mode 100644 index 00000000..cf4f1904 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/track_id_history.rs @@ -0,0 +1,123 @@ +//! Read-side helper for `track_id_history`. Writes live inside the +//! upsert transaction in `TrackRepository::upsert_batch_with_remap` +//! (spec §6.9) — keeping them there avoids splitting the remap across +//! two SQLite transactions, which would leave a window where child +//! tables point at a removed track id. + +use rusqlite::{params, OptionalExtension}; + +use crate::store::LibraryStore; + +pub struct TrackIdHistoryRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> TrackIdHistoryRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Resolve a remapped id forward to the current server id, if a + /// remap was recorded. Returns `None` when no row exists. Analysis + /// cache lookups (Phase E) go through this so cached waveform / + /// loudness rows stay reachable after the server's id space shifts. + pub fn lookup_new_id( + &self, + server_id: &str, + old_id: &str, + ) -> Result, String> { + self.store.with_conn("misc", |conn| { + conn.query_row( + "SELECT new_id FROM track_id_history \ + WHERE server_id = ?1 AND old_id = ?2", + params![server_id, old_id], + |row| row.get::<_, String>(0), + ) + .optional() + }) + } + + /// Count the rows recorded for this server — used by tests and by + /// post-sync diagnostics (Settings „Library index" panel later). + pub fn count_for_server(&self, server_id: &str) -> Result { + self.store.with_conn("misc", |conn| { + conn.query_row( + "SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1", + params![server_id], + |row| row.get(0), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + + fn seed_history(store: &LibraryStore, rows: &[(&str, &str, &str)]) { + store + .with_conn("misc", |c| { + for (server, old, new) in rows { + c.execute( + "INSERT INTO track_id_history \ + (server_id, old_id, new_id, content_hash, server_path, remapped_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![server, old, new, "hash", "path", 1_700_000_000_i64], + )?; + } + Ok(()) + }) + .unwrap(); + } + + #[test] + fn lookup_returns_none_when_no_remap_recorded() { + let store = LibraryStore::open_in_memory(); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!(repo.lookup_new_id("s1", "tr_old").unwrap(), None); + } + + #[test] + fn lookup_finds_new_id_for_recorded_remap() { + let store = LibraryStore::open_in_memory(); + seed_history(&store, &[("s1", "tr_old", "tr_new")]); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!( + repo.lookup_new_id("s1", "tr_old").unwrap().as_deref(), + Some("tr_new") + ); + } + + #[test] + fn lookup_scopes_by_server_id() { + let store = LibraryStore::open_in_memory(); + seed_history(&store, &[("s1", "tr_x", "tr_new1"), ("s2", "tr_x", "tr_new2")]); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!( + repo.lookup_new_id("s1", "tr_x").unwrap().as_deref(), + Some("tr_new1") + ); + assert_eq!( + repo.lookup_new_id("s2", "tr_x").unwrap().as_deref(), + Some("tr_new2") + ); + } + + #[test] + fn count_for_server_scopes_correctly() { + let store = LibraryStore::open_in_memory(); + seed_history( + &store, + &[ + ("s1", "a", "b"), + ("s1", "c", "d"), + ("s2", "e", "f"), + ], + ); + let repo = TrackIdHistoryRepository::new(&store); + assert_eq!(repo.count_for_server("s1").unwrap(), 2); + assert_eq!(repo.count_for_server("s2").unwrap(), 1); + assert_eq!(repo.count_for_server("absent").unwrap(), 0); + } +} diff --git a/src-tauri/crates/psysonic-library/src/runtime.rs b/src-tauri/crates/psysonic-library/src/runtime.rs new file mode 100644 index 00000000..97b9fc65 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/runtime.rs @@ -0,0 +1,258 @@ +//! `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 tokio::sync::Notify; + +use crate::store::LibraryStore; +use crate::sync::bandwidth::PlaybackHint; + +/// 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, +} + +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), + } + } + + /// 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; + } + } +} + +#[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" + ); + } +} diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs new file mode 100644 index 00000000..f0a12466 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -0,0 +1,338 @@ +//! FTS5-backed track search. Skeleton landed in PR-1a — the multi-server + +//! libraryScope + bm25 ranking shape from spec §5.9 will be filled in by the +//! sync / search PRs. + +use rusqlite::params; + +use crate::store::LibraryStore; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackHit { + pub server_id: String, + pub id: String, + pub title: String, + pub artist: Option, + pub album: String, +} + +/// Run a single-server FTS5 match against `track_fts`, returning rows in +/// bm25 order. `query` is passed straight to FTS5 — callers are expected to +/// sanitise / quote user input (see §5.13.5: parameterised only). +pub fn search_tracks( + store: &LibraryStore, + server_id: &str, + query: &str, + limit: i64, +) -> Result, String> { + if !fts_query_meets_min_len(query) { + return Ok(Vec::new()); + } + let fts = fts_track_match_query(query).ok_or_else(|| "empty query".to_string())?; + store.with_read_conn(|conn| { + let mut stmt = conn.prepare( + r#" + SELECT t.server_id, t.id, t.title, t.artist, t.album + FROM track_fts f + JOIN track t ON t.rowid = f.rowid + WHERE track_fts MATCH ?1 + AND t.server_id = ?2 + AND t.deleted = 0 + ORDER BY bm25(track_fts) + LIMIT ?3 + "#, + )?; + let rows = stmt + .query_map(params![fts, server_id, limit], |r| { + Ok(TrackHit { + server_id: r.get(0)?, + id: r.get(1)?, + title: r.get(2)?, + artist: r.get(3)?, + album: r.get(4)?, + }) + })? + .collect::>>()?; + Ok(rows) + }) +} + +// ── shared search SQL helpers (Advanced Search §5.13 + cross-server §5.5B) ── + +/// Hard ceiling on a single search page — keeps the FTS5 p95 budget (§5.9). +/// Callers clamp their requested `limit` into `1..=PAGE_LIMIT_MAX`. +pub(crate) const PAGE_LIMIT_MAX: u32 = 500; + +/// Local FTS is skipped below this length — single-character queries (e.g. Cyrillic +/// «а», Latin «a») match huge fractions of a large library and bm25+LIMIT can +/// take tens of seconds (§5.9: no heavy work on every keystroke). +pub const LOCAL_FTS_MIN_QUERY_CHARS: usize = 2; + +/// True when `raw` has enough graphemes for a scoped FTS MATCH. +pub fn fts_query_meets_min_len(raw: &str) -> bool { + raw.trim().chars().count() >= LOCAL_FTS_MIN_QUERY_CHARS +} + +/// Build a safe FTS5 MATCH string: each whitespace token is quoted (and its +/// internal `"` doubled) so arbitrary user input can't trip FTS5 query +/// syntax. Tokens are implicitly AND-ed. `None` when the input has no tokens. +pub(crate) fn fts_query(raw: &str) -> Option { + let tokens = fts_token_expr(raw)?; + Some(tokens) +} + +/// Token expression only (`"a" "b"`), shared by column-scoped builders. +pub(crate) fn fts_token_expr(raw: &str) -> Option { + fts_token_expr_with(raw, false) +} + +/// Prefix token expression (`"a"* "b"*`) for Live Search as-you-type matching. +pub(crate) fn fts_prefix_token_expr(raw: &str) -> Option { + fts_token_expr_with(raw, true) +} + +fn fts_token_expr_with(raw: &str, prefix: bool) -> Option { + let tokens: Vec = raw + .split_whitespace() + .map(|t| { + let quoted = format!("\"{}\"", t.replace('"', "\"\"")); + if prefix { + format!("{quoted}*") + } else { + quoted + } + }) + .collect(); + if tokens.is_empty() { + None + } else { + Some(tokens.join(" ")) + } +} + +/// Column-scoped prefix match (`artist : "met"*` → Metallica). +pub(crate) fn fts_column_prefix_query(column: &str, raw: &str) -> Option { + fts_prefix_token_expr(raw).map(|tokens| format!("{column} : {tokens}")) +} + +/// Prefix variants for Live Search / Advanced Search as-you-type matching. +pub(crate) fn fts_track_prefix_match_query(raw: &str) -> Option { + fts_prefix_token_expr(raw).map(|tokens| { + ["title", "artist", "album", "album_artist"] + .iter() + .map(|col| format!("{col} : {tokens}")) + .collect::>() + .join(" OR ") + }) +} + +pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option { + fts_prefix_token_expr(raw).map(|tokens| { + format!("(album : {tokens} OR album_artist : {tokens})") + }) +} + +/// Song / track entity: match primary display fields (excludes `genre` to cut +/// noise and FTS fan-out on large libraries). +pub(crate) fn fts_track_match_query(raw: &str) -> Option { + fts_token_expr(raw).map(|tokens| { + ["title", "artist", "album", "album_artist"] + .iter() + .map(|col| format!("{col} : {tokens}")) + .collect::>() + .join(" OR ") + }) +} + +/// Project the `track` hot columns prefixed with `alias` (e.g. `t.title`), +/// in `repos::row_to_track_row`'s positional order so the Advanced Search / +/// cross-server builders can reuse the shared row mapper. +/// Effective library id for scoped search — hot column first, then common +/// OpenSubsonic / Navidrome keys in `raw_json` (legacy rows may only have JSON). +pub(crate) fn library_scope_match_sql(table_alias: &str) -> String { + format!( + "COALESCE(NULLIF({table_alias}.library_id, ''), \ + CAST(json_extract({table_alias}.raw_json, '$.libraryId') AS TEXT), \ + CAST(json_extract({table_alias}.raw_json, '$.library_id') AS TEXT), \ + CAST(json_extract({table_alias}.raw_json, '$.musicFolderId') AS TEXT))" + ) +} + +pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String { + format!("{} = ?", library_scope_match_sql(table_alias)) +} + +pub(crate) fn aliased_track_columns(alias: &str) -> String { + crate::repos::track_columns() + .split(',') + .map(|c| format!("{alias}.{}", c.trim())) + .collect::>() + .join(", ") +} + +/// Build a `%…%` LIKE pattern with the LIKE wildcards (`%`, `_`) and the +/// `\` escape char escaped, for use with `LIKE ? ESCAPE '\'`. Shared by the +/// Advanced Search album/artist name match and the cross-server fuzzy +/// title fallback (§5.9). +pub(crate) fn like_contains(raw: &str) -> String { + let escaped = raw + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + format!("%{escaped}%") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + + fn row(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: None, + album: album.into(), + album_id: None, + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn match_finds_track_by_title() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + row("s1", "t1", "Aurora", "Anna", "Skylines"), + row("s1", "t2", "Sunset", "Beth", "Skylines"), + ]) + .unwrap(); + let hits = search_tracks(&store, "s1", "aurora", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, "t1"); + } + + #[test] + fn match_filters_by_server_id() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + row("s1", "t1", "Aurora", "Anna", "Skylines"), + row("s2", "t1", "Aurora", "Anna", "Skylines"), + ]) + .unwrap(); + let hits = search_tracks(&store, "s2", "aurora", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].server_id, "s2"); + } + + #[test] + fn match_skips_deleted_rows() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[row("s1", "t1", "Aurora", "Anna", "Skylines")]) + .unwrap(); + let mut gone = row("s1", "t1", "Aurora", "Anna", "Skylines"); + gone.deleted = true; + repo.upsert_batch(&[gone]).unwrap(); + let hits = search_tracks(&store, "s1", "aurora", 10).unwrap(); + assert!(hits.is_empty()); + } + + #[test] + fn fts_column_prefix_query_scopes_to_one_column() { + assert_eq!( + fts_column_prefix_query("artist", "metal").as_deref(), + Some("artist : \"metal\"*") + ); + } + + #[test] + fn fts_prefix_token_expr_ands_multiword_prefixes() { + assert_eq!( + fts_prefix_token_expr("arch enemy").as_deref(), + Some("\"arch\"* \"enemy\"*") + ); + } + + #[test] + fn fts_track_prefix_match_query_or_across_display_columns() { + let q = fts_track_prefix_match_query("metal").unwrap(); + assert!(q.contains("title : \"metal\"*")); + assert!(q.contains("artist : \"metal\"*")); + assert!(!q.contains("genre")); + } + + #[test] + fn fts_album_prefix_match_query_includes_album_artist() { + assert_eq!( + fts_album_prefix_match_query("metal").as_deref(), + Some("(album : \"metal\"* OR album_artist : \"metal\"*)") + ); + } + + #[test] + fn fts_track_match_query_or_across_display_columns() { + let q = fts_track_match_query("manowar").unwrap(); + assert!(q.contains("title : \"manowar\"")); + assert!(q.contains("artist : \"manowar\"")); + assert!(!q.contains("genre")); + } + + #[test] + fn fts_query_meets_min_len_requires_two_graphemes() { + assert!(!fts_query_meets_min_len("a")); + assert!(!fts_query_meets_min_len("а")); + assert!(fts_query_meets_min_len("ab")); + assert!(fts_query_meets_min_len("ма")); + } + + #[test] + fn fts_query_quotes_tokens_and_doubles_inner_quotes() { + assert_eq!(fts_query("hello world").as_deref(), Some("\"hello\" \"world\"")); + assert_eq!(fts_query("a\"b").as_deref(), Some("\"a\"\"b\"")); + } + + #[test] + fn fts_query_is_none_for_blank_input() { + assert!(fts_query("").is_none()); + assert!(fts_query(" ").is_none()); + } + + #[test] + fn aliased_track_columns_prefixes_every_column() { + let cols = aliased_track_columns("t"); + assert!(cols.starts_with("t.server_id, t.id, t.title")); + assert!(cols.ends_with("t.raw_json")); + // One alias per column — count matches the shared column list. + assert_eq!(cols.matches("t.").count(), crate::repos::track_columns().split(',').count()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs new file mode 100644 index 00000000..d6938b1c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -0,0 +1,532 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use rusqlite::{params, Connection, OpenFlags}; +use tauri::Manager; + +/// Current head of the embedded migrations. Bump each time a new +/// `migrations/NNN_*.sql` is added. +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 5; + +/// Lowest applied schema version the current code can advance from purely +/// additively. If a DB carries a version below this, the breaking-bump hook +/// fires (spec §5.7 / P22): the library is treated as incompatible, must be +/// dropped, and initial sync must restart. +/// +/// At v1 launch this equals `LIBRARY_DB_SCHEMA_VERSION` — no real DB can +/// trip the hook. Bump independently of `SCHEMA_VERSION` only when a +/// migration cannot be expressed additively. +pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1; + +pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql"); +const MIGRATION_002_N1_BULK_UNRELIABLE: &str = + include_str!("../migrations/002_n1_bulk_unreliable.sql"); +const MIGRATION_003_TRACK_REMAP_INDEXES: &str = + include_str!("../migrations/003_track_remap_indexes.sql"); +const MIGRATION_004_TRACK_TITLE_INDEX: &str = + include_str!("../migrations/004_track_title_index.sql"); +const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str = + include_str!("../migrations/005_track_genre_year_indexes.sql"); + +/// Embedded migrations. Ordered ascending by `version`; the runner sorts +/// defensively before applying so the source order can stay readable. +const MIGRATIONS: &[(i64, &str)] = &[ + (1, INITIAL_SQL), + (2, MIGRATION_002_N1_BULK_UNRELIABLE), + (3, MIGRATION_003_TRACK_REMAP_INDEXES), + (4, MIGRATION_004_TRACK_TITLE_INDEX), + (5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MigrationOutcome { + /// Every missing migration was applied (or the DB was already at head). + Applied, + /// The DB carried a schema below `LIBRARY_DB_MIN_COMPATIBLE_VERSION`, + /// so the breaking-bump hook fired. Callers should treat the library + /// data as discarded and trigger a fresh initial sync (P22). + BreakingBump, +} + +/// In-memory tests share one DB across the read/write pair in a single store. +static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn in_memory_uri() -> String { + let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared") +} + +pub struct LibraryStore { + /// Writes, migrations, and sync ingest (single writer). + write_conn: Mutex, + /// Read-only handle for search / status / hydrate while sync writes (WAL). + read_conn: Mutex, + /// IS-3 bulk ingest in progress — read paths skip write-lock work. + bulk_ingest_active: AtomicBool, +} + +impl LibraryStore { + pub fn init(app: &tauri::AppHandle) -> Result { + let db_path = library_db_path(app)?; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + Self::open_file(&db_path) + } + + fn open_file(db_path: &Path) -> Result { + let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?; + configure_write_connection(&write_conn).map_err(|e| e.to_string())?; + run_migrations(&write_conn).map_err(|e| e.to_string())?; + let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + configure_read_connection(&read_conn).map_err(|e| e.to_string())?; + Ok(Self { + write_conn: Mutex::new(write_conn), + read_conn: Mutex::new(read_conn), + bulk_ingest_active: AtomicBool::new(false), + }) + } + + /// Build an in-memory DB with the production schema applied. + pub fn open_in_memory() -> Self { + let uri = in_memory_uri(); + let write_conn = Connection::open(&uri).expect("in-memory write connection"); + configure_write_connection(&write_conn).expect("write pragmas"); + run_migrations(&write_conn).expect("schema migration"); + let read_conn = Connection::open(&uri).expect("in-memory read connection"); + configure_read_connection(&read_conn).expect("read pragmas"); + Self { + write_conn: Mutex::new(write_conn), + read_conn: Mutex::new(read_conn), + bulk_ingest_active: AtomicBool::new(false), + } + } + + pub(crate) fn set_bulk_ingest_active(&self, active: bool) { + self.bulk_ingest_active + .store(active, Ordering::Release); + } + + pub(crate) fn bulk_ingest_active(&self) -> bool { + self.bulk_ingest_active.load(Ordering::Acquire) + } + + /// Writer connection — sync ingest, migrations, mutations. + pub(crate) fn with_conn( + &self, + op: &'static str, + f: impl FnOnce(&Connection) -> rusqlite::Result, + ) -> Result { + let lock_start = std::time::Instant::now(); + let conn = self + .write_conn + .lock() + .map_err(|_| "library store write lock poisoned".to_string())?; + let lock_wait_ms = lock_start.elapsed().as_millis(); + let exec_start = std::time::Instant::now(); + let out = f(&conn).map_err(|e| e.to_string()); + let exec_ms = exec_start.elapsed().as_millis(); + log_write_op(op, lock_wait_ms, exec_ms); + out + } + + /// Read-only connection — search, status, hydrate; does not block on sync writes. + pub(crate) fn with_read_conn( + &self, + f: impl FnOnce(&Connection) -> rusqlite::Result, + ) -> Result { + let conn = self + .read_conn + .lock() + .map_err(|_| "library store read lock poisoned".to_string())?; + f(&conn).map_err(|e| e.to_string()) + } + + pub(crate) fn with_conn_mut( + &self, + op: &'static str, + f: impl FnOnce(&mut Connection) -> rusqlite::Result, + ) -> Result { + self.with_conn_mut_timed(op, f).map(|(value, _)| value) + } + + pub(crate) fn with_conn_mut_timed( + &self, + op: &'static str, + f: impl FnOnce(&mut Connection) -> rusqlite::Result, + ) -> Result<(R, WriteOpTiming), String> { + let lock_start = std::time::Instant::now(); + let mut conn = self + .write_conn + .lock() + .map_err(|_| "library store write lock poisoned".to_string())?; + let lock_wait_ms = lock_start.elapsed().as_millis() as u64; + let exec_start = std::time::Instant::now(); + let out = f(&mut conn).map_err(|e| e.to_string())?; + let exec_ms = exec_start.elapsed().as_millis() as u64; + log_write_op(op, lock_wait_ms as u128, exec_ms as u128); + Ok((out, WriteOpTiming { lock_wait_ms, exec_ms })) + } +} + +/// Timing split returned to ingest progress (DevTools / terminal). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WriteOpTiming { + pub lock_wait_ms: u64, + pub exec_ms: u64, +} + +impl WriteOpTiming { + pub fn total_ms(&self) -> u64 { + self.lock_wait_ms.saturating_add(self.exec_ms) + } +} + +fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) { + if lock_wait_ms >= 1000 || exec_ms >= 1000 { + crate::app_eprintln!( + "[library-db] SLOW write op={op} lock_wait_ms={lock_wait_ms} exec_ms={exec_ms}" + ); + } else if lock_wait_ms >= 50 || exec_ms >= 200 { + crate::app_eprintln!("[library-db] write op={op} lock_wait_ms={lock_wait_ms} exec_ms={exec_ms}"); + } +} + +fn library_db_path(app: &tauri::AppHandle) -> Result { + let base = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(base.join("library.sqlite")) +} + +fn configure_write_connection(conn: &Connection) -> rusqlite::Result<()> { + conn.busy_timeout(Duration::from_secs(30))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "temp_store", "MEMORY")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + Ok(()) +} + +fn configure_read_connection(conn: &Connection) -> rusqlite::Result<()> { + conn.busy_timeout(Duration::from_secs(5))?; + conn.pragma_update(None, "foreign_keys", "ON")?; + // Search / browse hot path on large libraries (read-only handle). + conn.pragma_update(None, "cache_size", -64_000)?; + Ok(()) +} + +fn run_migrations(conn: &Connection) -> rusqlite::Result { + run_migrations_with( + conn, + MIGRATIONS, + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + handle_breaking_schema_bump, + ) +} + +/// Test-friendly entry point. Production code goes through `run_migrations`, +/// which fixes `migrations`, `min_compatible`, and `hook` to the prod values. +pub(crate) fn run_migrations_with( + conn: &Connection, + migrations: &[(i64, &str)], + min_compatible: i64, + hook: fn(&Connection, i64, i64) -> rusqlite::Result<()>, +) -> rusqlite::Result { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + );", + )?; + + // Breaking-bump detection only meaningful for already-initialised DBs. + let max_applied: Option = conn.query_row( + "SELECT MAX(version) FROM schema_migrations", + [], + |row| row.get::<_, Option>(0), + )?; + if let Some(max_applied) = max_applied { + if max_applied < min_compatible { + hook(conn, max_applied, LIBRARY_DB_SCHEMA_VERSION)?; + return Ok(MigrationOutcome::BreakingBump); + } + } + + let mut ordered: Vec<(i64, &str)> = migrations.iter().map(|(v, s)| (*v, *s)).collect(); + ordered.sort_by_key(|(v, _)| *v); + for (version, sql) in ordered { + let already: i64 = conn.query_row( + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?1", + params![version], + |row| row.get(0), + )?; + if already > 0 { + continue; + } + conn.execute_batch(sql)?; + conn.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))", + params![version], + )?; + } + Ok(MigrationOutcome::Applied) +} + +/// P22 breaking-schema-bump hook. PR-1b ships a no-op stub: the function +/// signature, call site, and `MigrationOutcome::BreakingBump` signal are in +/// place, but the actual library-drop + sync-reset logic lands when the +/// first real breaking bump happens. Until then the constants guarantee the +/// hook never fires on production data. +fn handle_breaking_schema_bump( + _conn: &Connection, + _max_applied: i64, + _target_version: i64, +) -> rusqlite::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_conn_sees_committed_writes_from_write_conn() { + let store = LibraryStore::open_in_memory(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO sync_state (server_id, library_scope, sync_phase) \ + VALUES ('s1', '', 'ready')", + [], + ) + }) + .unwrap(); + let phase: String = store + .with_read_conn(|c| { + c.query_row( + "SELECT sync_phase FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(phase, "ready"); + } + + #[test] + fn open_in_memory_creates_all_expected_tables() { + let store = LibraryStore::open_in_memory(); + let tables = store + .with_conn("misc", |c| { + let mut stmt = + c.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?; + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get::<_, String>(0))?.collect(); + rows + }) + .unwrap(); + + for expected in [ + "album", + "artist", + "canonical_enrichment_link", + "canonical_identity", + "canonical_track", + "schema_migrations", + "sync_state", + "track", + "track_artifact", + "track_canonical_link", + "track_extension", + "track_fact", + "track_id_history", + "track_offline", + ] { + assert!( + tables.iter().any(|t| t == expected), + "missing table `{expected}` — got {tables:?}" + ); + } + } + + #[test] + fn schema_migrations_records_head_version() { + let store = LibraryStore::open_in_memory(); + let versions: Vec = store + .with_conn("misc", |c| { + let mut stmt = + c.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get(0))?.collect(); + rows + }) + .unwrap(); + // Embedded migrations are numbered 1..=head, all applied on a fresh DB. + let expected: Vec = (1..=LIBRARY_DB_SCHEMA_VERSION).collect(); + assert_eq!(versions, expected); + } + + #[test] + fn run_migrations_is_idempotent_across_reopens() { + let store = LibraryStore::open_in_memory(); + let outcome = store + .with_conn("migrate", run_migrations) + .expect("second migration pass must be a no-op"); + assert_eq!(outcome, MigrationOutcome::Applied); + let count: i64 = store + .with_conn("misc", |c| { + c.query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0)) + }) + .unwrap(); + assert_eq!( + count, LIBRARY_DB_SCHEMA_VERSION, + "one schema_migrations row per embedded migration, no duplicates" + ); + } + + #[test] + fn fts_virtual_table_exists() { + let store = LibraryStore::open_in_memory(); + let count: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='track_fts'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(count, 1); + } + + // ── PR-1b: edge-case tests via the test-only `run_migrations_with` ───── + + /// `ALTER TABLE artist ADD COLUMN bio TEXT;` — minimal additive fixture, + /// nullable column with no default. Mirrors the §5.7 additive-first rule. + /// Numbered above the real embedded head so it stacks on a migrated DB. + const FIXTURE_ADD_BIO: &str = "ALTER TABLE artist ADD COLUMN bio TEXT;"; + const FIXTURE_ADD_BIO_VERSION: i64 = LIBRARY_DB_SCHEMA_VERSION + 1; + + fn no_op_hook(_c: &Connection, _from: i64, _to: i64) -> rusqlite::Result<()> { + Ok(()) + } + + fn always_fail_hook(_c: &Connection, _from: i64, _to: i64) -> rusqlite::Result<()> { + panic!("breaking-bump hook must NOT fire in this test"); + } + + #[test] + fn additive_migration_preserves_existing_data() { + let store = LibraryStore::open_in_memory(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO artist (server_id, id, name, synced_at) \ + VALUES ('s1', 'a1', 'Existing Artist', 1)", + [], + ) + }) + .unwrap(); + + let outcome = store + .with_conn("misc", |c| { + run_migrations_with( + c, + &[(1, INITIAL_SQL), (FIXTURE_ADD_BIO_VERSION, FIXTURE_ADD_BIO)], + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + always_fail_hook, + ) + }) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::Applied); + + let (name, bio): (String, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT name, bio FROM artist WHERE id = 'a1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!(name, "Existing Artist"); + assert!(bio.is_none()); + + let versions: Vec = store + .with_conn("misc", |c| { + let mut stmt = + c.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get(0))?.collect(); + rows + }) + .unwrap(); + // Real embedded migrations (1..=head) plus the additive fixture. + let mut expected: Vec = (1..=LIBRARY_DB_SCHEMA_VERSION).collect(); + expected.push(FIXTURE_ADD_BIO_VERSION); + assert_eq!(versions, expected); + } + + #[test] + fn runner_sorts_unsorted_migration_slice_before_applying() { + // If a future contributor lists migrations out of order in the + // source slice, the runner must still apply them ascending. + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + + let outcome = run_migrations_with( + &conn, + &[(2, FIXTURE_ADD_BIO), (1, INITIAL_SQL)], + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + always_fail_hook, + ) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::Applied); + + let versions: Vec = { + let mut stmt = conn + .prepare("SELECT version FROM schema_migrations ORDER BY applied_at, version") + .unwrap(); + let rows: rusqlite::Result> = + stmt.query_map([], |r| r.get(0)).unwrap().collect(); + rows.unwrap() + }; + assert_eq!(versions, vec![1, 2]); + } + + #[test] + fn breaking_bump_hook_fires_when_db_below_min_compatible() { + // Simulate a future code release where MIN_COMPATIBLE was bumped past + // the version the DB currently carries (the real embedded head). + let store = LibraryStore::open_in_memory(); + let outcome = store + .with_conn("misc", |c| { + run_migrations_with( + c, + MIGRATIONS, + LIBRARY_DB_SCHEMA_VERSION + 1, // bumped past current applied + no_op_hook, + ) + }) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::BreakingBump); + } + + #[test] + fn breaking_bump_hook_does_not_fire_on_fresh_db() { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + let outcome = run_migrations_with( + &conn, + MIGRATIONS, + // Even a wildly future min_compatible must not trip on a fresh DB: + // no rows in schema_migrations means "nothing to migrate from". + 999, + always_fail_hook, + ) + .unwrap(); + assert_eq!(outcome, MigrationOutcome::Applied); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/backoff.rs b/src-tauri/crates/psysonic-library/src/sync/backoff.rs new file mode 100644 index 00000000..4030e334 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/backoff.rs @@ -0,0 +1,151 @@ +//! C12 — HTTP backoff for ingest batches (spec §6.8). +//! +//! The runner does not advance its cursor on transient failure; it +//! sleeps via `Backoff::next_delay`, retries the same batch, and +//! resets the counter on success. The cap is 120 s — the user is +//! waiting for sync to finish, longer hangs become indistinguishable +//! from "stuck" without progress events to reassure them. + +use std::time::Duration; + +/// Exponential schedule per §6.8: `2s → 4s → 8s → … cap 120s`. The +/// caller adds jitter on top to avoid herd-sync against a recovering +/// upstream proxy. +#[derive(Debug, Clone)] +pub struct Backoff { + attempt: u32, + base: Duration, + cap: Duration, +} + +impl Default for Backoff { + fn default() -> Self { + Self::new(Duration::from_secs(2), Duration::from_secs(120)) + } +} + +impl Backoff { + pub fn new(base: Duration, cap: Duration) -> Self { + Self { attempt: 0, base, cap } + } + + /// Reset after a successful batch — the next failure starts at + /// the base delay again. + pub fn reset(&mut self) { + self.attempt = 0; + } + + /// Compute the next sleep duration and bump the attempt counter. + /// Doubles each call (2 → 4 → 8 → …) and clamps to `cap`. Caller + /// adds jitter via `with_jitter` if desired. + pub fn next_delay(&mut self) -> Duration { + let n = self.attempt; + self.attempt = self.attempt.saturating_add(1); + let scaled = self.base.saturating_mul(1u32 << n.min(31)); + scaled.min(self.cap) + } + + pub fn attempt(&self) -> u32 { + self.attempt + } +} + +/// Salt for production jitter: attempt plus sub-second clock noise so +/// concurrent retries don't share the same jitter slot. +pub fn jitter_salt(attempt: u32) -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos() as u64) + .unwrap_or(0); + u64::from(attempt).saturating_add(nanos) +} + +/// Add ±25% jitter to a planned sleep — deterministic per `salt` so +/// tests can pin the result without pulling in `rand`. Production +/// code passes `jitter_salt(attempt)` as the salt; tests pass a fixed +/// value to assert the formula. +pub fn with_jitter(base: Duration, salt: u64) -> Duration { + let millis = base.as_millis().min(u128::from(u64::MAX)) as u64; + if millis == 0 { + return base; + } + let span = millis / 2; // ±25% = total span of 50% of base + if span == 0 { + return base; + } + // map salt into [-span/2, +span/2] + let pct = (salt % span) as i64 - (span as i64 / 2); + let jittered = (millis as i64).saturating_add(pct).max(1) as u64; + Duration::from_millis(jittered) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_delay_doubles_until_cap() { + let mut b = Backoff::default(); + assert_eq!(b.next_delay(), Duration::from_secs(2)); + assert_eq!(b.next_delay(), Duration::from_secs(4)); + assert_eq!(b.next_delay(), Duration::from_secs(8)); + assert_eq!(b.next_delay(), Duration::from_secs(16)); + assert_eq!(b.next_delay(), Duration::from_secs(32)); + assert_eq!(b.next_delay(), Duration::from_secs(64)); + // Next would be 128 — clamps to 120 cap. + assert_eq!(b.next_delay(), Duration::from_secs(120)); + // Stays at cap from here on. + assert_eq!(b.next_delay(), Duration::from_secs(120)); + } + + #[test] + fn reset_brings_schedule_back_to_base() { + let mut b = Backoff::default(); + b.next_delay(); + b.next_delay(); + assert!(b.attempt() > 0); + b.reset(); + assert_eq!(b.attempt(), 0); + assert_eq!(b.next_delay(), Duration::from_secs(2)); + } + + #[test] + fn next_delay_handles_custom_base_and_cap() { + let mut b = Backoff::new(Duration::from_millis(50), Duration::from_secs(1)); + assert_eq!(b.next_delay(), Duration::from_millis(50)); + assert_eq!(b.next_delay(), Duration::from_millis(100)); + assert_eq!(b.next_delay(), Duration::from_millis(200)); + assert_eq!(b.next_delay(), Duration::from_millis(400)); + assert_eq!(b.next_delay(), Duration::from_millis(800)); + // 1600 would exceed 1s cap. + assert_eq!(b.next_delay(), Duration::from_secs(1)); + } + + #[test] + fn next_delay_does_not_overflow_at_extreme_attempts() { + let mut b = Backoff::default(); + for _ in 0..100 { + // Should saturate at the cap, never panic on shift overflow. + let _ = b.next_delay(); + } + assert_eq!(b.next_delay(), Duration::from_secs(120)); + } + + #[test] + fn jitter_stays_within_plus_minus_half_of_span() { + let base = Duration::from_secs(8); + let span_ms = base.as_millis() as u64 / 2; // 4000 ms + let lo = base.as_millis() as u64 - span_ms / 2; // 6000 ms + let hi = base.as_millis() as u64 + span_ms / 2; // 10000 ms + for salt in 0u64..1000 { + let j = with_jitter(base, salt).as_millis() as u64; + assert!(j >= lo && j <= hi, "salt {salt} → {j}ms outside [{lo},{hi}]"); + } + } + + #[test] + fn jitter_with_zero_base_returns_zero() { + assert_eq!(with_jitter(Duration::ZERO, 12345), Duration::ZERO); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/bandwidth.rs b/src-tauri/crates/psysonic-library/src/sync/bandwidth.rs new file mode 100644 index 00000000..e7793122 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/bandwidth.rs @@ -0,0 +1,86 @@ +//! C11 — bandwidth priority lane (spec §6.2.4). +//! +//! PR-3d2 ships the data types + parallelism resolver. The signal +//! itself is pushed in from the top crate (PR-5 hooks audio engine +//! events from `psysonic-audio` into a shared `PlaybackHint` cell); +//! the library side just consumes it. + +/// What the player is currently doing — drives crawl parallelism. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlaybackHint { + /// Nothing playing, queue idle → bulk crawl runs at normal + /// parallelism. + #[default] + Idle, + /// Active stream (user listening). Bulk drops to single-request + /// crawling and increases inter-request delay. + Playing, + /// Queue prefetch / waveform analysis hot — priority lane only; + /// bulk pauses entirely. + PrefetchActive, +} + +/// Parallelism budget the bulk crawl uses for this tick. `0` means +/// bulk is suspended this tick (PR-3d2 returns the value; the runner +/// honoring it is wired in PR-3d2 too via `BackgroundScheduler`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParallelismBudget { + pub max_concurrent: u32, + /// Minimum gap between successive bulk requests, in ms. + pub min_request_gap_ms: u32, +} + +impl ParallelismBudget { + pub fn resolve(hint: PlaybackHint) -> Self { + match hint { + PlaybackHint::Idle => Self { + max_concurrent: 4, + min_request_gap_ms: 0, + }, + PlaybackHint::Playing => Self { + max_concurrent: 1, + min_request_gap_ms: 250, + }, + PlaybackHint::PrefetchActive => Self { + max_concurrent: 0, + min_request_gap_ms: 0, + }, + } + } + + pub fn bulk_paused(&self) -> bool { + self.max_concurrent == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_resolves_to_normal_parallelism() { + let b = ParallelismBudget::resolve(PlaybackHint::Idle); + assert_eq!(b.max_concurrent, 4); + assert_eq!(b.min_request_gap_ms, 0); + assert!(!b.bulk_paused()); + } + + #[test] + fn playing_caps_parallelism_to_one_with_inter_request_gap() { + let b = ParallelismBudget::resolve(PlaybackHint::Playing); + assert_eq!(b.max_concurrent, 1); + assert!(b.min_request_gap_ms >= 100, "playing must space requests out"); + assert!(!b.bulk_paused()); + } + + #[test] + fn prefetch_active_pauses_bulk_crawl_entirely() { + let b = ParallelismBudget::resolve(PlaybackHint::PrefetchActive); + assert!(b.bulk_paused()); + } + + #[test] + fn playback_hint_default_is_idle() { + assert_eq!(PlaybackHint::default(), PlaybackHint::Idle); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/budget.rs b/src-tauri/crates/psysonic-library/src/sync/budget.rs new file mode 100644 index 00000000..a5139029 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/budget.rs @@ -0,0 +1,93 @@ +//! C9 — request budget per spec §6.2.5. +//! +//! Soft caps the scheduler declares before each pass; runners +//! consume them to decide when to defer the remainder to the next +//! tick. PR-3d2 ships the data type + lookups; PR-5 / the runner +//! wires the actual consumption (delta runner can already be capped +//! via batch_size — the budget is a higher-level abstraction). + +/// Per-pass HTTP call budgets matching the spec table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PassKind { + /// `poll_tick (unchanged watermark)` — 1 call (`getArtists` only). + PollTick, + /// `delta_sync (light)` — small targeted delta, 50 calls. + DeltaLight, + /// `delta_sync (count mismatch / tombstone)` — 200 calls, + /// split across ticks if exhausted. + DeltaMismatch, + /// Initial sync — unlimited (only user cancel stops it). + InitialSync, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestBudget { + pub kind: PassKind, + /// `None` = unlimited. + pub cap: Option, +} + +impl RequestBudget { + pub const POLL_TICK_CAP: u32 = 1; + pub const DELTA_LIGHT_CAP: u32 = 50; + pub const DELTA_MISMATCH_CAP: u32 = 200; + + pub fn for_pass(kind: PassKind) -> Self { + let cap = match kind { + PassKind::PollTick => Some(Self::POLL_TICK_CAP), + PassKind::DeltaLight => Some(Self::DELTA_LIGHT_CAP), + PassKind::DeltaMismatch => Some(Self::DELTA_MISMATCH_CAP), + PassKind::InitialSync => None, + }; + Self { kind, cap } + } + + pub fn is_unlimited(&self) -> bool { + self.cap.is_none() + } + + /// Returns `true` when `used` requests still fit inside the cap. + /// Unlimited budgets always return `true`. + pub fn has_room(&self, used: u32) -> bool { + match self.cap { + None => true, + Some(cap) => used < cap, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poll_tick_cap_is_one_request() { + let b = RequestBudget::for_pass(PassKind::PollTick); + assert_eq!(b.cap, Some(1)); + assert!(b.has_room(0)); + assert!(!b.has_room(1)); + } + + #[test] + fn delta_light_caps_at_fifty() { + let b = RequestBudget::for_pass(PassKind::DeltaLight); + assert_eq!(b.cap, Some(50)); + assert!(b.has_room(49)); + assert!(!b.has_room(50)); + } + + #[test] + fn delta_mismatch_caps_at_two_hundred() { + let b = RequestBudget::for_pass(PassKind::DeltaMismatch); + assert_eq!(b.cap, Some(200)); + assert!(b.has_room(199)); + assert!(!b.has_room(200)); + } + + #[test] + fn initial_sync_is_unlimited() { + let b = RequestBudget::for_pass(PassKind::InitialSync); + assert!(b.is_unlimited()); + assert!(b.has_room(u32::MAX)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/capability.rs b/src-tauri/crates/psysonic-library/src/sync/capability.rs new file mode 100644 index 00000000..f7e47071 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/capability.rs @@ -0,0 +1,680 @@ +//! C1 — Capability probe (spec §6.1 / §6.1.1). +//! +//! Drives the Subsonic client + an optional Navidrome native probe to +//! populate `sync_state.capability_flags` before initial sync picks its +//! ingest strategy (§6.3). PR-3a only writes flags from the responses; +//! interpretation lives in PR-3b's `IngestStrategy` selector. + +use psysonic_integration::navidrome::probe::native_bulk_available; +use psysonic_integration::subsonic::{ServerInfo, SubsonicClient, SubsonicError}; + +/// Bitfield matching spec §6.1.1. `u32` storage so the `sync_state` +/// table can keep it as a single integer column (`capability_flags +/// INTEGER NOT NULL DEFAULT 0`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CapabilityFlags(u32); + +impl CapabilityFlags { + /// N1 — Navidrome native `/api/song` paginated ingest. + pub const NAVIDROME_NATIVE_BULK: u32 = 0x001; + /// S1 — Subsonic `search3` empty-query bulk ingest. + pub const SUBSONIC_SEARCH3_BULK: u32 = 0x002; + /// `getScanStatus` available (Subsonic 1.15+); cheap-poll tier signal. + pub const SCAN_STATUS_AVAILABLE: u32 = 0x004; + /// Server advertises OpenSubsonic extensions (`isrc`, `played`, + /// `bpm`, contributor arrays, …). + pub const OPEN_SUBSONIC: u32 = 0x008; + /// Track ids may shift across server re-indexing — sync engine must + /// run the `track_id_history` remap pass (§6.9, P33). Always set + /// for Navidrome. + pub const UNSTABLE_TRACK_IDS: u32 = 0x010; + /// S3 — `getIndexes` / `getMusicDirectory` available (file-tree + /// fallback when ID3 endpoints are missing entirely). + pub const FILE_TREE_BROWSE: u32 = 0x020; + + pub fn new(bits: u32) -> Self { + Self(bits) + } + + pub fn bits(self) -> u32 { + self.0 + } + + pub fn contains(self, flag: u32) -> bool { + self.0 & flag == flag + } + + pub fn insert(&mut self, flag: u32) { + self.0 |= flag; + } + + pub fn remove(&mut self, flag: u32) { + self.0 &= !flag; + } +} + +/// Optional input for `CapabilityProbe::run` — Navidrome native API +/// needs its own bearer token (separate from the Subsonic salted-md5 +/// auth). When `None`, the `NavidromeNativeBulk` bit stays clear and +/// sync falls back to Subsonic strategies. +#[derive(Debug, Clone)] +pub struct NavidromeProbeCredentials { + pub server_url: String, + pub bearer_token: String, +} + +/// Outcome of the capability probe — both the bitfield (stored in +/// `sync_state.capability_flags`) and the raw `ServerInfo` envelope +/// metadata (callers may want to log `serverVersion` etc.). +#[derive(Debug, Clone)] +pub struct CapabilityProbeResult { + pub flags: CapabilityFlags, + pub server_info: ServerInfo, + /// Server-reported track count from `getScanStatus.count`, when the + /// server exposes it. `None` when `getScanStatus` is unavailable or + /// reports no count. Persisted as the `server_track_count` watermark so + /// the strategy selector can route large catalogs to S1 at IS-1 without + /// first hitting N1's deep-offset wall (R7-15 Q4). + pub server_track_count: Option, +} + +/// Run `CapabilityProbe::run` and persist the resulting flags + +/// transition `sync_phase` from whatever it was to `probing` → +/// `idle` (caller is responsible for advancing to `initial_sync` / +/// `ready` once the appropriate runner starts). +/// +/// PR-3d wires this in front of every initial / delta run so the +/// stored `capability_flags` always reflects the current server. +/// Returns the freshly resolved `(flags, server_info)` so callers +/// can pick their `IngestStrategy` without re-reading SQLite. +pub async fn probe_and_persist( + store: &crate::store::LibraryStore, + subsonic: &psysonic_integration::subsonic::SubsonicClient, + navidrome: Option<&NavidromeProbeCredentials>, + server_id: &str, + library_scope: &str, +) -> Result { + let sync_state = crate::repos::SyncStateRepository::new(store); + sync_state + .ensure(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + let phase_before = sync_state + .get_sync_phase(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + sync_state + .set_sync_phase(server_id, library_scope, "probing") + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + + let existing_flags = sync_state + .get_capability_flags(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)? + .unwrap_or(0); + + let mut result = CapabilityProbe::run(subsonic, navidrome).await?; + + // R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it + // must not drop a previously-learned NavidromeNativeBulk capability — the + // server still supports `/api/song`; only the token is missing this bind. + // Token availability gates actual N1 use per run (see library_sync_start). + if navidrome.is_none() + && existing_flags & CapabilityFlags::NAVIDROME_NATIVE_BULK != 0 + { + result.flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK); + } + + sync_state + .set_capability_flags(server_id, library_scope, result.flags.bits()) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + // Refresh the track-count watermark only when the probe learned one — a + // missing `getScanStatus.count` must not clobber a count from a prior run. + if let Some(count) = result.server_track_count { + sync_state + .set_server_track_count(server_id, library_scope, count) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + } + sync_state + .set_sync_phase( + server_id, + library_scope, + match phase_before.as_deref() { + // Re-bind on app restart must not clobber a finished index — + // callers gate local search on `ready` (§9.3 / P8). + Some("ready") => "ready", + Some("initial_sync") => "initial_sync", + Some("error") => "error", + _ => { + if sync_state + .has_last_full_sync_at(server_id, library_scope) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)? + { + "ready" + } else { + "idle" + } + } + }, + ) + .map_err(psysonic_integration::subsonic::SubsonicError::Transport)?; + + Ok(result) +} + +pub struct CapabilityProbe; + +impl CapabilityProbe { + /// Run the §6.1 probe chain. Returns the resolved flags plus the + /// envelope metadata captured from the Subsonic ping. + /// + /// The Subsonic ping is the only failure-blocking probe — if it + /// returns `Err`, the server is unreachable / wrong creds / wrong + /// URL, and no other capability can be determined. Every other + /// probe is best-effort: it sets its flag on success and leaves it + /// clear on any error. + pub async fn run( + subsonic: &SubsonicClient, + navidrome: Option<&NavidromeProbeCredentials>, + ) -> Result { + let server_info = subsonic.server_info().await?; + + let mut flags = CapabilityFlags::default(); + + if server_info.open_subsonic { + flags.insert(CapabilityFlags::OPEN_SUBSONIC); + } + // Navidrome rebuilds its track id space on full re-scan; spec + // §6.9 / P33 makes the remap pass mandatory for those servers. + if matches!(server_info.server_type.as_deref(), Some("navidrome")) { + flags.insert(CapabilityFlags::UNSTABLE_TRACK_IDS); + } + + // `search3` with songCount=1 is the cheapest way to confirm the + // bulk-ingest endpoint is usable on this server (Navidrome + // accepts empty query; some forks reject it). + if subsonic.search3("", 1, 0, None).await.is_ok() { + flags.insert(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + } + + let mut server_track_count = None; + if let Ok(scan) = subsonic.get_scan_status().await { + flags.insert(CapabilityFlags::SCAN_STATUS_AVAILABLE); + // Only a positive count is a usable watermark; a scan in progress + // can report 0, which we treat as "unknown" rather than "empty". + server_track_count = scan.count.filter(|&c| c > 0); + } + + if subsonic.get_indexes(None, None).await.is_ok() { + flags.insert(CapabilityFlags::FILE_TREE_BROWSE); + } + + if let Some(creds) = navidrome { + match native_bulk_available(&creds.server_url, &creds.bearer_token).await { + Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK), + Ok(false) => {} + Err(_) => { + // Probe transport failed but Subsonic ping worked — + // assume the native endpoint is unavailable for this + // setup and let sync fall back to S1/S2. + } + } + } + + Ok(CapabilityProbeResult { + flags, + server_info, + server_track_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{header, method as wm_method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // ── CapabilityFlags bitfield ───────────────────────────────────────── + + #[test] + fn capability_flags_contains_respects_individual_bits() { + let mut f = CapabilityFlags::default(); + assert!(!f.contains(CapabilityFlags::OPEN_SUBSONIC)); + f.insert(CapabilityFlags::OPEN_SUBSONIC); + assert!(f.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(!f.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + } + + #[test] + fn capability_flags_insert_is_idempotent() { + let mut f = CapabilityFlags::default(); + f.insert(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + let after_first = f.bits(); + f.insert(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + assert_eq!(f.bits(), after_first); + } + + #[test] + fn capability_flags_remove_clears_only_the_named_bit() { + let mut f = CapabilityFlags::new( + CapabilityFlags::OPEN_SUBSONIC | CapabilityFlags::UNSTABLE_TRACK_IDS, + ); + f.remove(CapabilityFlags::OPEN_SUBSONIC); + assert!(!f.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(f.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)); + } + + #[test] + fn capability_flags_bit_values_match_spec_table() { + // Spec §6.1.1 hex values — pin the wire format so future + // schema-migration writers don't shift them silently. + assert_eq!(CapabilityFlags::NAVIDROME_NATIVE_BULK, 0x001); + assert_eq!(CapabilityFlags::SUBSONIC_SEARCH3_BULK, 0x002); + assert_eq!(CapabilityFlags::SCAN_STATUS_AVAILABLE, 0x004); + assert_eq!(CapabilityFlags::OPEN_SUBSONIC, 0x008); + assert_eq!(CapabilityFlags::UNSTABLE_TRACK_IDS, 0x010); + assert_eq!(CapabilityFlags::FILE_TREE_BROWSE, 0x020); + } + + // ── CapabilityProbe wiremock harness ───────────────────────────────── + + fn test_subsonic_client(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + fn ok_envelope(body_key: &str, body: serde_json::Value) -> serde_json::Value { + json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + body_key: body, + } + }) + } + + async fn mount_subsonic_full_navidrome(server: &MockServer) { + // ping → navidrome + openSubsonic + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "0.55.2", + "openSubsonic": true + } + }))) + .mount(server) + .await; + // search3 empty query + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "searchResult3", + json!({ "song": [{ "id": "x", "title": "y" }] }), + ))) + .mount(server) + .await; + // getScanStatus + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "scanStatus", + json!({ "scanning": false }), + ))) + .mount(server) + .await; + // getIndexes + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "indexes", + json!({ "lastModified": 0, "ignoredArticles": "", "index": [] }), + ))) + .mount(server) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_sets_all_subsonic_bits_on_a_fully_capable_navidrome_server() { + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + .await + .unwrap(); + assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); + assert!(result.flags.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE)); + assert!(result.flags.contains(CapabilityFlags::FILE_TREE_BROWSE)); + assert!(result.flags.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(result.flags.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)); + // No navidrome probe creds passed → N1 stays clear. + assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + assert_eq!(result.server_info.server_type.as_deref(), Some("navidrome")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_returns_err_when_subsonic_ping_fails() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 40, "message": "Wrong credentials" } + } + }))) + .mount(&server) + .await; + + let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + .await + .unwrap_err(); + assert!(matches!(err, SubsonicError::Api { code: 40, .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_keeps_optional_bits_clear_when_their_endpoint_fails() { + // Minimal Subsonic-like server: ping ok, search3 ok, but + // scanStatus + getIndexes 4xx. UnstableTrackIds + OpenSubsonic + // stay clear because the ping envelope omits them. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.13" } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "searchResult3", + json!({}), + ))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 30, "message": "Method not available" } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None) + .await + .unwrap(); + assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK)); + assert!(!result.flags.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE)); + assert!(!result.flags.contains(CapabilityFlags::FILE_TREE_BROWSE)); + assert!(!result.flags.contains(CapabilityFlags::OPEN_SUBSONIC)); + assert!(!result.flags.contains(CapabilityFlags::UNSTABLE_TRACK_IDS)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_sets_navidrome_native_bulk_when_credentials_succeed() { + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav)) + .await + .unwrap(); + assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + } + + // ── probe_and_persist round-trip ────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn probe_and_persist_writes_flags_and_resets_phase_to_idle() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let store = LibraryStore::open_in_memory(); + let result = super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + + let sync_state = SyncStateRepository::new(&store); + let flags = sync_state.get_capability_flags("s1", "").unwrap().unwrap(); + assert_eq!(flags, result.flags.bits()); + assert!(flags & CapabilityFlags::OPEN_SUBSONIC != 0); + assert!(flags & CapabilityFlags::UNSTABLE_TRACK_IDS != 0); + + // Fresh server ends at `idle` so the caller can transition to + // `initial_sync` / `ready` based on whether a sync is needed. + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("idle") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_and_persist_preserves_ready_phase_on_rebind() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_sync_phase("s1", "", "ready").unwrap(); + + super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("ready") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_and_persist_promotes_idle_to_ready_when_full_sync_stamped() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_sync_phase("s1", "", "idle").unwrap(); + sync_state + .set_last_full_sync_at("s1", "", 1_716_000_000_000) + .unwrap(); + + super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("ready") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_captures_and_persists_scan_status_track_count() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let server = MockServer::start().await; + // ping + search3 + getIndexes from the shared helper, then override + // getScanStatus with a populated `count` (large library). + Mock::given(wm_method("GET")) + .and(wm_path("/rest/ping.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "version": "1.16.1", "type": "navidrome" } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "searchResult3", + json!({ "song": [{ "id": "x", "title": "y" }] }), + ))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "scanStatus", + json!({ "scanning": false, "count": 170_000 }), + ))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getIndexes.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope( + "indexes", + json!({ "lastModified": 0, "ignoredArticles": "", "index": [] }), + ))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let result = + super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "") + .await + .unwrap(); + assert_eq!(result.server_track_count, Some(170_000)); + + let sync_state = SyncStateRepository::new(&store); + assert_eq!( + sync_state.get_server_track_count("s1", "").unwrap(), + Some(170_000) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_preserves_navidrome_native_bulk_when_no_token_supplied() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + // A prior bind (with a working bearer) already learned N1. + sync_state + .set_capability_flags("s1", "", CapabilityFlags::NAVIDROME_NATIVE_BULK) + .unwrap(); + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + + // Re-probe without a Navidrome token (transient /auth/login failure). + // R7-15 Q3: the server still supports /api/song — the flag must stay. + let result = super::probe_and_persist( + &store, + &test_subsonic_client(&server.uri()), + None, + "s1", + "", + ) + .await + .unwrap(); + assert!( + result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK), + "result must keep the previously-learned N1 capability" + ); + let persisted = sync_state.get_capability_flags("s1", "").unwrap().unwrap(); + assert!( + persisted & CapabilityFlags::NAVIDROME_NATIVE_BULK != 0, + "persisted flags must keep N1 across a token-less probe" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_does_not_clobber_track_count_when_scan_status_omits_it() { + use crate::repos::SyncStateRepository; + use crate::store::LibraryStore; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + // A prior run already learned the count. + sync_state.set_server_track_count("s1", "", 52_000).unwrap(); + + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; // scanStatus has no count + + let result = + super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "") + .await + .unwrap(); + assert_eq!(result.server_track_count, None); + // Watermark from the prior run survives the count-less probe. + assert_eq!( + sync_state.get_server_track_count("s1", "").unwrap(), + Some(52_000) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn probe_leaves_navidrome_native_bulk_clear_when_endpoint_404s() { + let server = MockServer::start().await; + mount_subsonic_full_navidrome(&server).await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav)) + .await + .unwrap(); + assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/cursor.rs b/src-tauri/crates/psysonic-library/src/sync/cursor.rs new file mode 100644 index 00000000..eebcedcc --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/cursor.rs @@ -0,0 +1,157 @@ +//! Cursor shape persisted in `sync_state.initial_sync_cursor_json`. +//! Resume after a restart, kill, or app crash deserializes this value +//! and tells the runner where to pick up. Strategy is recorded so a +//! cap-flag change between runs is detected and the stale cursor is +//! reset — the runner restarts ingest under the newly-selected strategy +//! rather than resuming the wrong loop. + +use serde::{Deserialize, Serialize}; + +use super::strategy::IngestStrategy; + +/// Top-level cursor. `phase` advances `Ingest → ArtistPass → Watermarks +/// → Done`; each phase only reads the fields it owns. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InitialSyncCursor { + /// Ingest strategy in flight — stored as the tag string so the JSON + /// is human-readable on disk. + pub strategy: String, + pub phase: CursorPhase, + /// Scope this run operates on (Navidrome `library_id` / Subsonic + /// `musicFolderId`). `None` means "all libraries on this server". + #[serde(default)] + pub library_scope: Option, + /// Tracks ingested across the entire run so far — informational, + /// matches the §6 progress event payload. + #[serde(default)] + pub ingested_count: u32, + /// Per-strategy offset state. Discriminated by `strategy` so a + /// future field add doesn't break old cursors. + #[serde(default)] + pub strategy_state: StrategyState, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CursorPhase { + Ingest, + ArtistPass, + Watermarks, + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum StrategyState { + /// N1 / S1 — single linear offset. + LinearOffset { offset: u32 }, + /// S2 — outer album-list offset plus optional in-flight album we + /// were halfway through when interrupted. + AlbumCrawl { + album_offset: u32, + #[serde(default)] + current_album_id: Option, + }, + /// Fresh cursor with no progress yet. + #[default] + Empty, +} + +impl InitialSyncCursor { + pub fn fresh(strategy: IngestStrategy, library_scope: Option) -> Self { + let strategy_state = match strategy { + IngestStrategy::N1 | IngestStrategy::S1 => StrategyState::LinearOffset { offset: 0 }, + IngestStrategy::S2 => StrategyState::AlbumCrawl { + album_offset: 0, + current_album_id: None, + }, + IngestStrategy::S3 => StrategyState::Empty, + }; + Self { + strategy: strategy.as_tag().to_string(), + phase: CursorPhase::Ingest, + library_scope, + ingested_count: 0, + strategy_state, + } + } + + pub fn strategy_tag(&self) -> &str { + &self.strategy + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn fresh_n1_starts_at_offset_zero() { + let c = InitialSyncCursor::fresh(IngestStrategy::N1, None); + assert_eq!(c.phase, CursorPhase::Ingest); + assert_eq!(c.ingested_count, 0); + match c.strategy_state { + StrategyState::LinearOffset { offset } => assert_eq!(offset, 0), + other => panic!("expected LinearOffset, got {other:?}"), + } + } + + #[test] + fn fresh_s2_uses_album_crawl_state() { + let c = InitialSyncCursor::fresh(IngestStrategy::S2, Some("lib-1".into())); + assert_eq!(c.library_scope.as_deref(), Some("lib-1")); + match c.strategy_state { + StrategyState::AlbumCrawl { album_offset, current_album_id } => { + assert_eq!(album_offset, 0); + assert!(current_album_id.is_none()); + } + other => panic!("expected AlbumCrawl, got {other:?}"), + } + } + + #[test] + fn cursor_roundtrips_through_json() { + // The cursor lives as TEXT in `sync_state.initial_sync_cursor_json`; + // serde must keep the round trip stable for resume to work. + let c = InitialSyncCursor { + strategy: "n1".into(), + phase: CursorPhase::Ingest, + library_scope: Some("lib-1".into()), + ingested_count: 2500, + strategy_state: StrategyState::LinearOffset { offset: 2500 }, + }; + let json = serde_json::to_value(&c).unwrap(); + let back: InitialSyncCursor = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(c, back); + + // strategy_state internally tagged with `"kind"`. + assert_eq!( + json.get("strategy_state").and_then(|s| s.get("kind")), + Some(&json!("linear_offset")) + ); + } + + #[test] + fn cursor_deserialize_tolerates_omitted_defaults() { + // Minimal cursor produced by a much older client must still + // deserialize as a "fresh ingest" state. + let raw = json!({ + "strategy": "s1", + "phase": "ingest" + }); + let c: InitialSyncCursor = serde_json::from_value(raw).unwrap(); + assert_eq!(c.ingested_count, 0); + assert_eq!(c.library_scope, None); + assert!(matches!(c.strategy_state, StrategyState::Empty)); + } + + #[test] + fn empty_object_does_not_parse_as_cursor() { + // sync_state.initial_sync_cursor_json default is `'{}'`; that + // value is not a valid cursor (no strategy / phase). Runner + // must treat it as "no cursor → start fresh". + let raw = json!({}); + assert!(serde_json::from_value::(raw).is_err()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/delta.rs b/src-tauri/crates/psysonic-library/src/sync/delta.rs new file mode 100644 index 00000000..4e1c5fb1 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/delta.rs @@ -0,0 +1,1049 @@ +//! C3 — `DeltaSyncRunner` (spec §6.4 DS-0 … DS-9). Drives a targeted +//! re-fetch when the server reports new content since the last +//! successful sync. Compared to `InitialSyncRunner`: +//! +//! - Cheap probe first (DS-0 / DS-2) — short-circuits to zero further +//! requests on the happy path. +//! - Strategy choice from `capability_flags`: N1-delta when Navidrome +//! native bulk is available, otherwise S2-delta via +//! `getAlbumList2 type=newest + recent`. S1 (`search3` empty query) +//! doesn't carry a delta semantic so it's not used here. +//! - No artist/album index pass — DS-9 only re-stamps watermarks + +//! `last_delta_sync_at`. Browse acceleration tables stay in sync +//! incrementally via the initial pass and a future PR-3d hook. +//! +//! DS-5 canonical matcher and DS-7 starred delta are explicitly out +//! of scope for PR-3c (Phase H / follow-up). + +use std::collections::HashSet; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use psysonic_integration::navidrome::queries::nd_list_songs_internal; +use psysonic_integration::subsonic::SubsonicClient; +use serde_json::Value; + +use super::backoff::{jitter_salt, with_jitter, Backoff}; +use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; +use super::error::SyncError; +use super::mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; +use super::progress::{NoopProgress, Progress, ProgressEvent}; +use super::strategy::IngestStrategy; +use super::tombstone::TombstoneReconciler; +use crate::repos::{SyncStateRepository, TrackRepository, TrackRow}; +use crate::store::LibraryStore; + +/// Default batch size for delta pages — same as initial sync; servers +/// already tolerate 500-row pages at scale. +const DEFAULT_BATCH_SIZE: u32 = 500; + +/// Maximum attempts per page before propagating. Same as initial sync. +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +/// How many `getAlbumList2 type=newest + recent` pages the S2-delta +/// loop walks before stopping. 2× DEFAULT_BATCH_SIZE = 1000 most-recent +/// albums per type per pass — enough overlap on small/medium libs to +/// catch every change between polls. +const S2_DELTA_MAX_PAGES_PER_TYPE: u32 = 4; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DeltaSyncReport { + pub strategy: Option, + /// `true` when DS-2 short-circuited — server watermark matched + /// local; no tracks were touched. + pub up_to_date: bool, + /// `true` when DS-3 saw an active scan and deferred. Caller + /// re-runs the delta on the next tick. + pub deferred_scanning: bool, + /// Track upserts performed during DS-4. + pub changed_count: u32, + pub remapped_count: u32, + /// Tombstone chunk stats from DS-8 — `0` when the runner wasn't + /// configured with `with_tombstone_budget`. + pub tombstones_checked: u32, + pub tombstones_deleted: u32, +} + +pub struct DeltaSyncRunner<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + navidrome: Option, + server_id: String, + library_scope: String, + capability_flags: CapabilityFlags, + cancel: Option>, + batch_size: u32, + sleep_enabled: bool, + /// DS-8 budget. `None` skips the tombstone chunk entirely; `Some(n)` + /// drives `TombstoneReconciler::reconcile_chunk(n)` after DS-4. + tombstone_budget: Option, + progress: Arc, +} + +impl<'a> DeltaSyncRunner<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + library_scope: impl Into, + capability_flags: CapabilityFlags, + ) -> Self { + Self { + store, + subsonic, + navidrome: None, + server_id: server_id.into(), + library_scope: library_scope.into(), + capability_flags, + cancel: None, + batch_size: DEFAULT_BATCH_SIZE, + sleep_enabled: true, + tombstone_budget: None, + progress: Arc::new(NoopProgress), + } + } + + pub fn with_navidrome_credentials(mut self, creds: NavidromeProbeCredentials) -> Self { + self.navidrome = Some(creds); + self + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_batch_size(mut self, n: u32) -> Self { + if n > 0 { + self.batch_size = n; + } + self + } + + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + /// DS-8 — run a `TombstoneReconciler::reconcile_chunk(budget)` + /// pass after DS-4 ingest. Caller (PR-3d scheduler) decides + /// budget based on §6.7 threshold detection and per-tick limits. + pub fn with_tombstone_budget(mut self, budget: u32) -> Self { + self.tombstone_budget = Some(budget); + self + } + + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = progress; + self + } + + /// DS-0 … DS-9. Returns a report describing what happened — caller + /// (PR-3d background scheduler) decides whether to re-tick on + /// `deferred_scanning`. + pub async fn run(&self) -> Result { + let sync_state = SyncStateRepository::new(self.store); + sync_state + .ensure(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + + let mut report = DeltaSyncReport::default(); + + // DS-0 / DS-1 / DS-2 / DS-3 — poll + watermark compare. + let probe = self.poll_for_change(&sync_state).await?; + report.deferred_scanning = probe.deferred_scanning; + if probe.deferred_scanning { + return Ok(report); + } + if probe.up_to_date { + report.up_to_date = true; + self.stamp_last_delta(&sync_state)?; + return Ok(report); + } + + // DS-4 — targeted ingest. Strategy choice matches initial sync + // but S1 is skipped: `search3` doesn't carry a delta semantic. + let strategy = self.delta_strategy(); + report.strategy = Some(strategy.as_tag().to_string()); + self.progress.emit(ProgressEvent::PhaseChanged { + phase: format!("delta:{}", strategy.as_tag()), + }); + match strategy { + IngestStrategy::N1 => self.run_n1_delta(&mut report).await?, + IngestStrategy::S2 => self.run_s2_delta(&mut report).await?, + IngestStrategy::S1 | IngestStrategy::S3 => { + return Err(SyncError::StrategyUnsupported { + strategy: strategy.as_tag(), + }) + } + } + + // DS-8 — optional tombstone chunk (PR-3d wiring). Runs after + // ingest so newly-arrived rows are already in `track` before + // we probe `getSong` for stale ids. + if let Some(budget) = self.tombstone_budget { + if budget > 0 { + let mut reconciler = + TombstoneReconciler::new(self.store, self.subsonic, &self.server_id); + if !self.sleep_enabled { + reconciler = reconciler.with_sleep_disabled(); + } + if let Some(flag) = &self.cancel { + reconciler = reconciler.with_cancellation(Arc::clone(flag)); + } + let stats = reconciler.reconcile_chunk(budget).await?; + report.tombstones_checked = stats.checked; + report.tombstones_deleted = stats.deleted; + self.progress.emit(ProgressEvent::Tombstoned { + deleted_count: stats.deleted, + checked_count: stats.checked, + }); + } + } + + // DS-9 — stamp watermarks. + if let Some(ms) = probe.next_artists_watermark { + sync_state + .set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms) + .map_err(SyncError::Storage)?; + } + if let Some(iso) = probe.next_last_scan_iso.as_deref() { + sync_state + .set_server_last_scan_iso( + &self.server_id, + &self.library_scope, + Some(iso), + ) + .map_err(SyncError::Storage)?; + } + self.stamp_last_delta(&sync_state)?; + + self.progress.emit(ProgressEvent::Completed { + kind: "delta_sync".into(), + }); + Ok(report) + } + + // ── helpers ──────────────────────────────────────────────────────── + + fn check_cancellation(&self) -> Result<(), SyncError> { + if let Some(flag) = &self.cancel { + if flag.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + } + Ok(()) + } + + fn unstable_track_ids(&self) -> bool { + self.capability_flags + .contains(CapabilityFlags::UNSTABLE_TRACK_IDS) + } + + fn library_scope_opt(&self) -> Option<&str> { + if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.as_str()) + } + } + + async fn sleep(&self, d: Duration) { + if self.sleep_enabled && !d.is_zero() { + tokio::time::sleep(d).await; + } + } + + fn write_batch(&self, rows: &[TrackRow]) -> Result<(u32, u32), SyncError> { + let stats = TrackRepository::new(self.store) + .upsert_batch_with_remap(rows, self.unstable_track_ids()) + .map_err(SyncError::Storage)?; + Ok((rows.len() as u32, stats.remapped.len() as u32)) + } + + fn delta_strategy(&self) -> IngestStrategy { + if self + .capability_flags + .contains(CapabilityFlags::NAVIDROME_NATIVE_BULK) + { + IngestStrategy::N1 + } else { + // S1 has no delta semantic — fall through to album-crawl. + IngestStrategy::S2 + } + } + + fn stamp_last_delta(&self, sync_state: &SyncStateRepository<'_>) -> Result<(), SyncError> { + sync_state + .set_last_delta_sync_at(&self.server_id, &self.library_scope, now_unix_ms()) + .map_err(SyncError::Storage) + } + + fn local_track_updated_watermark(&self) -> Result, SyncError> { + self.store + .with_conn("misc", |c| { + c.query_row( + "SELECT MAX(server_updated_at) FROM track \ + WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![self.server_id], + |row| row.get::<_, Option>(0), + ) + }) + .map_err(SyncError::Storage) + } + + fn local_album_ids(&self) -> Result, SyncError> { + self.store + .with_conn("misc", |c| { + let mut stmt = c.prepare( + "SELECT DISTINCT album_id FROM track \ + WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL", + )?; + let rows: rusqlite::Result> = stmt + .query_map(rusqlite::params![self.server_id], |r| { + r.get::<_, String>(0) + })? + .collect(); + rows + }) + .map_err(SyncError::Storage) + } + + // ── DS-0 / DS-1 / DS-2 / DS-3 — poll + watermark compare ─────────── + + async fn poll_for_change( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result { + let tier = sync_state + .get_library_tier(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)? + .unwrap_or_else(|| "unknown".to_string()); + + let mut outcome = DeltaPollOutcome::default(); + + let use_scan_status = tier == "huge" + && self + .capability_flags + .contains(CapabilityFlags::SCAN_STATUS_AVAILABLE); + + if use_scan_status { + let scan = self.subsonic.get_scan_status().await?; + // DS-3 — defer when a scan is in flight on the server. + if scan.scanning { + outcome.deferred_scanning = true; + return Ok(outcome); + } + // DS-2 — watermark match → short-circuit. + let stored = sync_state + .get_server_last_scan_iso(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + if let (Some(stored), Some(live)) = (stored.as_deref(), scan.last_scan.as_deref()) { + if stored == live { + outcome.up_to_date = true; + return Ok(outcome); + } + } + outcome.next_last_scan_iso = scan.last_scan; + } else { + // Small/medium tier (or unknown): `getArtists` carries + // `lastModified` which is the watermark. + let scope = self.library_scope_opt(); + let artists = self.subsonic.get_artists(scope).await?; + let stored = sync_state + .get_artists_last_modified_ms(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + if let (Some(stored), Some(live)) = (stored, artists.last_modified_ms) { + if stored == live { + outcome.up_to_date = true; + return Ok(outcome); + } + } + outcome.next_artists_watermark = artists.last_modified_ms; + } + + Ok(outcome) + } + + // ── DS-4 N1-delta — Navidrome native /api/song _sort=updated_at ─── + + async fn run_n1_delta(&self, report: &mut DeltaSyncReport) -> Result<(), SyncError> { + let creds = self.navidrome.as_ref().ok_or_else(|| { + SyncError::Transport("n1-delta selected but no Navidrome credentials supplied".into()) + })?; + let watermark = self.local_track_updated_watermark()?; + + let mut offset: u32 = 0; + loop { + self.check_cancellation()?; + let end = offset.saturating_add(self.batch_size); + let response = retry_with_backoff( + self, + || { + nd_list_songs_internal( + &creds.server_url, + &creds.bearer_token, + "updated_at", + "DESC", + offset, + end, + ) + }, + SyncError::Navidrome, + ) + .await?; + + let array = response.as_array().cloned().unwrap_or_default(); + if array.is_empty() { + break; + } + + let synced_at = now_unix_ms(); + let mut rows: Vec = Vec::with_capacity(array.len()); + let mut crossed_watermark = false; + for v in &array { + if let Some(row) = navidrome_song_to_track_row( + &self.server_id, + v, + synced_at, + self.library_scope_opt(), + ) { + if let (Some(watermark), Some(server_updated)) = + (watermark, row.server_updated_at) + { + if server_updated < watermark { + crossed_watermark = true; + continue; + } + } + rows.push(row); + } + } + + if !rows.is_empty() { + let (changed, remapped) = self.write_batch(&rows)?; + report.changed_count = report.changed_count.saturating_add(changed); + report.remapped_count = report.remapped_count.saturating_add(remapped); + } + + if crossed_watermark || (array.len() as u32) < self.batch_size { + break; + } + offset = end; + } + Ok(()) + } + + // ── DS-4 S2-delta — getAlbumList2 newest + recent, getAlbum diff ── + + async fn run_s2_delta(&self, report: &mut DeltaSyncReport) -> Result<(), SyncError> { + let scope = self.library_scope_opt(); + let known_albums = self.local_album_ids()?; + let mut seen_albums: HashSet = HashSet::new(); + + for list_type in ["newest", "recent"] { + let mut offset: u32 = 0; + for _ in 0..S2_DELTA_MAX_PAGES_PER_TYPE { + self.check_cancellation()?; + let page = retry_with_backoff( + self, + || { + self.subsonic + .get_album_list2(list_type, self.batch_size, offset, scope) + }, + SyncError::from, + ) + .await?; + if page.is_empty() { + break; + } + let page_len = page.len() as u32; + + for album_summary in page { + if !seen_albums.insert(album_summary.id.clone()) { + continue; + } + // S2-delta only fetches album bodies the local + // store doesn't already have. `recent` (`getAlbumList2 + // type=recent`) returns play-time order, so a + // known album that just got played still skips + // the song-list re-fetch. + if known_albums.contains(&album_summary.id) { + continue; + } + self.check_cancellation()?; + let (album, raw_album) = retry_with_backoff( + self, + || self.subsonic.get_album_with_raw(&album_summary.id), + SyncError::from, + ) + .await?; + + let synced_at = now_unix_ms(); + let raw_songs = raw_album + .get("song") + .and_then(|s| s.as_array()) + .cloned() + .unwrap_or_default(); + let mut rows: Vec = Vec::with_capacity(album.song.len()); + for (i, song) in album.song.iter().enumerate() { + let raw = raw_songs + .get(i) + .cloned() + .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); + rows.push(subsonic_song_to_track_row( + &self.server_id, + song, + &raw, + synced_at, + self.library_scope_opt(), + )); + } + if !rows.is_empty() { + let (changed, remapped) = self.write_batch(&rows)?; + report.changed_count = report.changed_count.saturating_add(changed); + report.remapped_count = + report.remapped_count.saturating_add(remapped); + } + } + + if page_len < self.batch_size { + break; + } + offset = offset.saturating_add(self.batch_size); + } + } + Ok(()) + } +} + +#[derive(Debug, Default)] +struct DeltaPollOutcome { + deferred_scanning: bool, + up_to_date: bool, + next_last_scan_iso: Option, + next_artists_watermark: Option, +} + +fn now_unix_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +async fn retry_with_backoff<'a, F, FFut, T, E>( + runner: &DeltaSyncRunner<'a>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> FFut, + FFut: std::future::Future>, +{ + let mut backoff = Backoff::default(); + let mut attempt = 0u32; + loop { + runner.check_cancellation()?; + attempt += 1; + match build().await { + Ok(v) => return Ok(v), + Err(e) => { + let mapped = map_err(e); + if !is_retryable(&mapped) || attempt >= MAX_ATTEMPTS_PER_BATCH { + return Err(mapped); + } + let delay = backoff.next_delay(); + let jittered = with_jitter(delay, jitter_salt(attempt)); + runner.sleep(jittered).await; + } + } + } +} + +fn is_retryable(e: &SyncError) -> bool { + matches!(e, SyncError::Transport(_) | SyncError::Navidrome(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::TrackRow; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_subsonic(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + fn flags(bits: u32) -> CapabilityFlags { + CapabilityFlags::new(bits) + } + + fn seed_track(store: &LibraryStore, id: &str, album_id: &str, server_updated_at: i64) { + TrackRepository::new(store) + .upsert_batch(&[TrackRow { + server_id: "s1".into(), + id: id.into(), + title: "seed".into(), + title_sort: None, + artist: None, + artist_id: None, + album: "A".into(), + album_id: Some(album_id.into()), + album_artist: None, + duration_sec: 240, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: Some(server_updated_at), + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + }]) + .unwrap(); + } + + // ── DS-2: getArtists watermark match → short-circuit ───────────── + + #[tokio::test(flavor = "multi_thread")] + async fn ds2_short_circuits_when_artists_watermark_matches() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1_700_000_000_000_i64, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_artists_last_modified_ms("s1", "", 1_700_000_000_000) + .unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = DeltaSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert!(report.up_to_date); + assert_eq!(report.changed_count, 0); + assert!(!report.deferred_scanning); + } + + // ── DS-3: huge-tier scan in progress → defer ───────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn ds3_defers_when_getscanstatus_is_scanning() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "scanStatus": { "scanning": true, "count": 10000 } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_library_tier("s1", "", "huge").unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = DeltaSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert!(report.deferred_scanning); + assert!(!report.up_to_date); + assert_eq!(report.changed_count, 0); + } + + // ── DS-4 N1-delta crosses watermark and stops ──────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn n1_delta_stops_at_local_watermark() { + let server = MockServer::start().await; + // getArtists path: claim new lastModified to trigger DS-4. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1_716_840_000_000_i64, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(&server) + .await; + // /api/song _sort=updated_at _order=DESC: 3 fresh, then 2 stale + // (server_updated_at < watermark). + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "0")) + .and(query_param("_sort", "updated_at")) + .and(query_param("_order", "DESC")) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": "tr_n3", "title": "new3", "updatedAt": "2024-06-03T00:00:00Z" }, + { "id": "tr_n2", "title": "new2", "updatedAt": "2024-06-02T00:00:00Z" }, + { "id": "tr_n1", "title": "new1", "updatedAt": "2024-06-01T00:00:00Z" }, + { "id": "tr_old1", "title": "old", "updatedAt": "2024-01-01T00:00:00Z" }, + { "id": "tr_old2", "title": "old", "updatedAt": "2024-01-01T00:00:00Z" } + ]))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + // Seed a track with server_updated_at = 2024-05-01 — fresh3..1 + // are newer (above watermark); old1/old2 are older (below). + seed_track(&store, "tr_old_seed", "al_x", parse_test_iso("2024-05-01")); + + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let subsonic = test_subsonic(&server.uri()); + let report = DeltaSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::NAVIDROME_NATIVE_BULK), + ) + .with_navidrome_credentials(nav) + .with_batch_size(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.changed_count, 3, "only the 3 fresh rows upserted"); + assert_eq!(report.strategy.as_deref(), Some("n1")); + } + + // ── DS-4 S2-delta only fetches unknown album ids ───────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s2_delta_skips_known_album_ids() { + let server = MockServer::start().await; + // Watermark change: getArtists lastModified differs from stored + // (null) → falls through to DS-4. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1_716_840_000_000_i64, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(&server) + .await; + // getAlbumList2 type=newest page 0: two albums, one we already + // have locally and one fresh. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("type", "newest")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { + "album": [ + { "id": "al_known", "name": "Known" }, + { "id": "al_fresh", "name": "Fresh" } + ] + } + } + }))) + .mount(&server) + .await; + // Empty pages after the first one for both types. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [] } + } + }))) + .mount(&server) + .await; + // getAlbum body for the fresh id only. If "al_known" is + // accidentally fetched, the test mock returns 404 by default + // and the runner errors out — that's the assertion. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", "al_fresh")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": "al_fresh", + "name": "Fresh", + "song": [ + { "id": "tr_new", "title": "Just landed", "duration": 240 } + ] + } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + seed_track(&store, "tr_existing", "al_known", 1_000); + + let subsonic = test_subsonic(&server.uri()); + let report = DeltaSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s2")); + assert_eq!(report.changed_count, 1, "only the fresh album got upserted"); + // The seed plus the new track land in the store. + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + // ── DS-9 watermarks land + last_delta_sync_at stamped ──────────── + + #[tokio::test(flavor = "multi_thread")] + async fn ds9_writes_watermarks_and_last_delta_timestamp() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1_716_840_000_000_i64, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [] } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + DeltaSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + let sync_state = SyncStateRepository::new(&store); + assert_eq!( + sync_state + .get_artists_last_modified_ms("s1", "") + .unwrap(), + Some(1_716_840_000_000) + ); + let (last_delta,): (Option,) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT last_delta_sync_at FROM sync_state WHERE server_id='s1'", + [], + |r| Ok((r.get(0)?,)), + ) + }) + .unwrap(); + assert!(last_delta.unwrap_or(0) > 0); + } + + // ── DS-8: tombstone wire runs after DS-4 ───────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn ds8_runs_tombstone_chunk_when_budget_set() { + let server = MockServer::start().await; + // Watermark change → DS-4 ingest path runs. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1_716_840_000_000_i64, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(&server) + .await; + // S2-delta: empty album list → no ingest, but DS-8 still runs. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [] } + } + }))) + .mount(&server) + .await; + // getSong probe — first id returns ok, second returns code 70. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_alive")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { "id": "tr_alive", "title": "Alive" } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_gone")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + seed_track(&store, "tr_alive", "al_x", 1_000); + seed_track(&store, "tr_gone", "al_x", 1_000); + + let subsonic = test_subsonic(&server.uri()); + let report = DeltaSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_tombstone_budget(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.tombstones_checked, 2); + assert_eq!(report.tombstones_deleted, 1); + + // tr_gone is now soft-deleted. + let gone_deleted: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT deleted FROM track WHERE id='tr_gone'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(gone_deleted, 1); + } + + fn parse_test_iso(s: &str) -> i64 { + // Tiny helper for the seed track watermark — full date only, + // midnight UTC, ms epoch. + let mut parts = s.split('-'); + let y: i64 = parts.next().unwrap().parse().unwrap(); + let m: i64 = parts.next().unwrap().parse().unwrap(); + let d: i64 = parts.next().unwrap().parse().unwrap(); + let y2 = if m <= 2 { y - 1 } else { y }; + let era = y2.div_euclid(400); + let yoe = y2 - era * 400; + let mm = if m > 2 { m - 3 } else { m + 9 }; + let doy = (153 * mm + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + days * 86_400_000 + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/error.rs b/src-tauri/crates/psysonic-library/src/sync/error.rs new file mode 100644 index 00000000..27189962 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/error.rs @@ -0,0 +1,149 @@ +//! Errors surfaced by the sync orchestrator. Designed so callers +//! (PR-5 Tauri command surface) can pattern-match on the variant for +//! UI affordances — `Cancelled` is silent, `Transport`/`HttpStatus` +//! retry, `Subsonic { code: 70, .. }` flows into the tombstone path, +//! and `StrategyUnsupported` surfaces a Settings hint. + +use std::fmt; + +use psysonic_integration::subsonic::SubsonicError; + +#[derive(Debug)] +pub enum SyncError { + /// Network / TLS / DNS failure surfaced by the Subsonic or + /// Navidrome HTTP client. Retryable per §6.8. + Transport(String), + + /// Subsonic-level error after the envelope parsed cleanly. The + /// dedicated `NotFound` (error code 70) is kept inline rather + /// than collapsed into a string so the tombstone path can match. + Subsonic { code: i32, message: String }, + + /// Subsonic returned error code 70 — track missing from the + /// server. Tombstone reconciler matches on this variant directly. + NotFound, + + /// Navidrome native REST returned a non-success status; carries + /// the flattened HTTP error string from `nd_err`. + Navidrome(String), + + /// Persistence layer (SQLite) failure. + Storage(String), + + /// Strategy is enumerated but not implemented for v1 + /// (currently only `S3`). + StrategyUnsupported { + strategy: &'static str, + }, + + /// Cancellation token tripped — caller asked us to abort. + /// Cursor stays where it was so the next run resumes the batch. + Cancelled, + + /// Cursor JSON in `sync_state` is from an incompatible strategy + /// (e.g. switching from N1 to S2 mid-sync). Caller should clear + /// the cursor and restart initial sync. + CursorIncompatible { + expected: &'static str, + actual: String, + }, +} + +impl fmt::Display for SyncError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(m) => write!(f, "sync transport: {m}"), + Self::Subsonic { code, message } => write!(f, "subsonic {code}: {message}"), + Self::NotFound => write!(f, "subsonic: not found (code 70)"), + Self::Navidrome(m) => write!(f, "navidrome: {m}"), + Self::Storage(m) => write!(f, "storage: {m}"), + Self::StrategyUnsupported { strategy } => { + write!(f, "ingest strategy not supported: {strategy}") + } + Self::Cancelled => write!(f, "sync cancelled"), + Self::CursorIncompatible { expected, actual } => write!( + f, + "sync cursor strategy mismatch: cursor says `{actual}`, runner is `{expected}`" + ), + } + } +} + +impl std::error::Error for SyncError {} + +impl SyncError { + /// Parsed HTTP status when this is a Navidrome native REST failure + /// shaped like `HTTP 500` or `HTTP 500: body`. + pub fn navidrome_http_status(&self) -> Option { + let SyncError::Navidrome(msg) = self else { + return None; + }; + let rest = msg.strip_prefix("HTTP ")?.split(':').next()?.trim(); + rest.split_whitespace().next()?.parse().ok() + } +} + +impl From for SyncError { + fn from(e: SubsonicError) -> Self { + match e { + SubsonicError::Transport(m) => Self::Transport(m), + SubsonicError::HttpStatus(s) => Self::Transport(format!("http {s}")), + SubsonicError::Api { code, message } => Self::Subsonic { code, message }, + SubsonicError::NotFound => Self::NotFound, + SubsonicError::Decode(m) => Self::Transport(format!("decode: {m}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subsonic_not_found_maps_to_sync_not_found() { + let e: SyncError = SubsonicError::NotFound.into(); + assert!(matches!(e, SyncError::NotFound)); + } + + #[test] + fn subsonic_api_error_carries_code_through() { + let e: SyncError = SubsonicError::Api { code: 40, message: "bad creds".into() }.into(); + match e { + SyncError::Subsonic { code, message } => { + assert_eq!(code, 40); + assert!(message.contains("bad creds")); + } + other => panic!("expected Subsonic, got {other:?}"), + } + } + + #[test] + fn http_status_collapses_into_transport() { + let e: SyncError = SubsonicError::HttpStatus(reqwest::StatusCode::SERVICE_UNAVAILABLE).into(); + assert!(matches!(e, SyncError::Transport(ref m) if m.contains("503"))); + } + + #[test] + fn navidrome_http_status_parses_status_line() { + let e = SyncError::Navidrome("HTTP 500".into()); + assert_eq!(e.navidrome_http_status(), Some(500)); + let with_body = SyncError::Navidrome("HTTP 503: upstream timeout".into()); + assert_eq!(with_body.navidrome_http_status(), Some(503)); + let with_reason = SyncError::Navidrome("HTTP 500 Internal Server Error".into()); + assert_eq!(with_reason.navidrome_http_status(), Some(500)); + assert_eq!( + SyncError::Transport("http 500".into()).navidrome_http_status(), + None + ); + } + + #[test] + fn cursor_incompatible_renders_both_strategies() { + let s = SyncError::CursorIncompatible { + expected: "n1", + actual: "s2".into(), + } + .to_string(); + assert!(s.contains("n1") && s.contains("s2")); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs b/src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs new file mode 100644 index 00000000..12e7cc07 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/ingest_parallel.rs @@ -0,0 +1,226 @@ +//! Concurrent fetch helpers for initial-sync ingest (C11 parallelism budget). + +use std::collections::BTreeMap; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use psysonic_integration::subsonic::{Album, SubsonicClient}; +use serde_json::Value; +use tokio::sync::Semaphore; +use tokio::task::JoinHandle; + +use super::backoff::{jitter_salt, with_jitter, Backoff}; +use super::bandwidth::ParallelismBudget; +use super::error::SyncError; + +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +pub fn check_cancel_flag(cancel: &Option>) -> Result<(), SyncError> { + if cancel.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) { + return Err(SyncError::Cancelled); + } + Ok(()) +} + +fn is_retryable(e: &SyncError) -> bool { + matches!(e, SyncError::Transport(_) | SyncError::Navidrome(_)) +} + +/// How many linear pages to keep in flight (`1` = sequential). +pub fn linear_prefetch_depth(budget: &ParallelismBudget) -> usize { + if budget.bulk_paused() { + return 1; + } + budget.max_concurrent.max(1) as usize +} + +pub async fn sleep_request_gap(budget: &ParallelismBudget, sleep_enabled: bool) { + if sleep_enabled && budget.min_request_gap_ms > 0 { + tokio::time::sleep(Duration::from_millis(budget.min_request_gap_ms as u64)).await; + } +} + +pub async fn wait_while_bulk_paused( + budget: &ParallelismBudget, + sleep_enabled: bool, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, +) -> Result<(), SyncError> { + while budget.bulk_paused() { + check_cancel()?; + if sleep_enabled { + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + Ok(()) +} + +/// Standalone retry loop for spawned prefetch tasks (no `InitialSyncRunner` ref). +pub async fn retry_fetch( + sleep_enabled: bool, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut backoff = Backoff::default(); + let mut attempt = 0u32; + loop { + check_cancel()?; + attempt += 1; + match build().await { + Ok(v) => return Ok(v), + Err(e) => { + let mapped = map_err(e); + if !is_retryable(&mapped) || attempt >= MAX_ATTEMPTS_PER_BATCH { + return Err(mapped); + } + let delay = backoff.next_delay(); + let jittered = with_jitter(delay, jitter_salt(attempt)); + if sleep_enabled && !jittered.is_zero() { + tokio::time::sleep(jittered).await; + } + } + } + } +} + +/// Ordered in-flight page queue for N1/S1 linear ingest. +pub struct LinearPrefetchQueue { + depth: usize, + batch_size: u32, + next_enqueue_offset: u32, + exhausted: bool, + inflight: BTreeMap>>, +} + +impl LinearPrefetchQueue { + pub fn new(budget: &ParallelismBudget, batch_size: u32, start_offset: u32) -> Self { + Self { + depth: linear_prefetch_depth(budget), + batch_size, + next_enqueue_offset: start_offset, + exhausted: false, + inflight: BTreeMap::new(), + } + } + + pub fn mark_exhausted(&mut self) { + self.exhausted = true; + } + + pub fn pump( + &mut self, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, + mut spawn: F, + ) -> Result<(), SyncError> + where + F: FnMut(u32) -> JoinHandle>, + { + while !self.exhausted && self.inflight.len() < self.depth { + check_cancel()?; + let offset = self.next_enqueue_offset; + self.next_enqueue_offset = offset.saturating_add(self.batch_size); + let handle = spawn(offset); + self.inflight.insert(offset, handle); + } + Ok(()) + } + + pub async fn take_at( + &mut self, + offset: u32, + mut check_cancel: impl FnMut() -> Result<(), SyncError>, + ) -> Result, SyncError> { + check_cancel()?; + let Some(handle) = self.inflight.remove(&offset) else { + return Ok(None); + }; + let value = handle + .await + .map_err(|e| SyncError::Transport(format!("prefetch join: {e}")))??; + Ok(Some(value)) + } +} + +/// Fetch `getAlbum` bodies for a page with at most `budget.max_concurrent` +/// requests in flight. Results preserve input order. +#[derive(Clone)] +pub struct ParallelAlbumFetchOpts { + pub budget: ParallelismBudget, + pub sleep_enabled: bool, + pub cancel: Option>, +} + +pub async fn fetch_albums_parallel( + subsonic: &SubsonicClient, + album_ids: &[String], + opts: ParallelAlbumFetchOpts, +) -> Result, SyncError> { + if album_ids.is_empty() { + return Ok(Vec::new()); + } + wait_while_bulk_paused(&opts.budget, opts.sleep_enabled, || check_cancel_flag(&opts.cancel)).await?; + let max = opts.budget.max_concurrent.max(1) as usize; + let client = subsonic.clone(); + let sem = Arc::new(Semaphore::new(max)); + let mut handles: Vec>> = + Vec::with_capacity(album_ids.len()); + + for id in album_ids { + check_cancel_flag(&opts.cancel)?; + sleep_request_gap(&opts.budget, opts.sleep_enabled).await; + let permit = sem + .clone() + .acquire_owned() + .await + .map_err(|_| SyncError::Transport("parallel fetch semaphore closed".into()))?; + let client = client.clone(); + let id = id.clone(); + let cancel = opts.cancel.clone(); + let sleep_enabled = opts.sleep_enabled; + handles.push(tokio::spawn(async move { + let _permit = permit; + retry_fetch( + sleep_enabled, + || check_cancel_flag(&cancel), + || async { + client + .get_album_with_raw(&id) + .await + .map_err(SyncError::from) + }, + |e| e, + ) + .await + })); + } + + let mut out = Vec::with_capacity(handles.len()); + for handle in handles { + check_cancel_flag(&opts.cancel)?; + out.push( + handle + .await + .map_err(|e| SyncError::Transport(format!("parallel album fetch join: {e}")))?? + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linear_prefetch_depth_respects_budget() { + let idle = ParallelismBudget::resolve(super::super::bandwidth::PlaybackHint::Idle); + assert_eq!(linear_prefetch_depth(&idle), 4); + let playing = ParallelismBudget::resolve(super::super::bandwidth::PlaybackHint::Playing); + assert_eq!(linear_prefetch_depth(&playing), 1); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/initial.rs b/src-tauri/crates/psysonic-library/src/sync/initial.rs new file mode 100644 index 00000000..461a0b70 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/initial.rs @@ -0,0 +1,2210 @@ +//! `InitialSyncRunner` — spec §6.3 IS-1 … IS-6. PR-3b lands the runner, +//! cursor persistence, and the N1/S1/S2 ingest loops. S3 (file-tree) +//! is enumerated but returns `StrategyUnsupported`. IS-4 artist pass + +//! IS-5 watermarks run after the bulk loop completes. +//! +//! The runner is pure Rust — no Tauri events, no background task +//! lifecycle. PR-3d wires it into a `tokio::task::spawn` shell with +//! progress emit + the cancellation token; PR-3b only ships the +//! library-side function the shell will call. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use psysonic_integration::navidrome::queries::nd_list_songs_internal; +use psysonic_integration::subsonic::SubsonicClient; +use serde_json::Value; + +use super::backoff::{jitter_salt, with_jitter, Backoff}; +use super::bandwidth::ParallelismBudget; +use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; +use super::cursor::{CursorPhase, InitialSyncCursor, StrategyState}; +use super::error::SyncError; +use super::ingest_parallel::{ + check_cancel_flag, fetch_albums_parallel, linear_prefetch_depth, retry_fetch, + sleep_request_gap, wait_while_bulk_paused, LinearPrefetchQueue, ParallelAlbumFetchOpts, +}; +use super::mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; +use super::progress::{IngestBatchMetrics, NoopProgress, Progress, ProgressEvent}; +use super::strategy::IngestStrategy; +use crate::bulk_ingest::{restore_track_secondary_indexes, suspend_track_secondary_indexes}; +use crate::repos::{RemapStats, SyncStateRepository, TrackRepository, TrackRow}; +use crate::store::LibraryStore; +use crate::store::WriteOpTiming; +use crate::track_fts::{ + rebuild_track_fts_from_content, restore_track_fts_triggers, suspend_track_fts_triggers, +}; + +/// Bulk ingest batch size per spec §6.3 (`batch=500`). +const DEFAULT_BATCH_SIZE: u32 = 500; + +/// Persist initial-sync cursor every N ingest batches (not every batch). +/// S2 already persists once per album-list page; N1/S1 match ~prefetch depth. +const CURSOR_PERSIST_EVERY_BATCHES: u32 = 4; + +/// Maximum attempts per batch before `SyncError::Transport` propagates. +/// Caller (Settings „retry" / PR-3d scheduler) can wrap and retry the +/// whole run if needed. +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +/// Suspends FTS + secondary indexes for IS-3; restores on drop. +struct BulkIngestGuard<'a> { + store: &'a LibraryStore, +} + +impl Drop for BulkIngestGuard<'_> { + fn drop(&mut self) { + self.store.set_bulk_ingest_active(false); + let start = std::time::Instant::now(); + match self.store.with_conn_mut("bulk.finalize", |conn| { + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "wal_autocheckpoint", 1000)?; + restore_track_secondary_indexes(conn)?; + let _: (i32, i32, i32) = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + rebuild_track_fts_from_content(conn)?; + restore_track_fts_triggers(conn) + }) { + Ok(()) => crate::app_eprintln!( + "[library-sync] bulk ingest finalized in {}ms (indexes + WAL + FTS)", + start.elapsed().as_millis() + ), + Err(e) => { + crate::app_eprintln!("[library-sync] bulk ingest finalize failed: {e}") + } + } + } +} + +/// N1 deep-offset safety line (R7-15 Q1/Q5). A `GET /api/song` HTTP 500 at +/// or beyond this offset is treated as Navidrome's server-side deep-offset +/// wall (observed past ~50k), not a transient error: the run learns +/// `n1_bulk_unreliable` and falls back to S1 rather than retrying smaller +/// windows that cannot recover. +const N1_DEEP_OFFSET_SAFE: u32 = 50_000; + +/// Summary returned from `InitialSyncRunner::run`. Caller emits a +/// completion event with these numbers (PR-3d). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InitialSyncReport { + pub strategy: Option, + pub ingested_count: u32, + pub remapped_count: u32, +} + +struct IngestPageCtx<'a> { + cursor: &'a mut InitialSyncCursor, + report: &'a mut InitialSyncReport, + sync_state: &'a SyncStateRepository<'a>, + batch_count: &'a mut u32, + force_persist: bool, +} + +pub struct InitialSyncRunner<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + navidrome: Option, + server_id: String, + library_scope: String, + capability_flags: CapabilityFlags, + cancel: Option>, + batch_size: u32, + n1_deep_offset_safe: u32, + sleep_enabled: bool, + progress: Arc, + parallelism: ParallelismBudget, +} + +impl<'a> InitialSyncRunner<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + library_scope: impl Into, + capability_flags: CapabilityFlags, + ) -> Self { + Self { + store, + subsonic, + navidrome: None, + server_id: server_id.into(), + library_scope: library_scope.into(), + capability_flags, + cancel: None, + batch_size: DEFAULT_BATCH_SIZE, + n1_deep_offset_safe: N1_DEEP_OFFSET_SAFE, + sleep_enabled: true, + progress: Arc::new(NoopProgress), + parallelism: ParallelismBudget::resolve(super::bandwidth::PlaybackHint::Idle), + } + } + + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = progress; + self + } + + pub fn with_navidrome_credentials(mut self, creds: NavidromeProbeCredentials) -> Self { + self.navidrome = Some(creds); + self + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_batch_size(mut self, n: u32) -> Self { + if n > 0 { + self.batch_size = n; + } + self + } + + /// Override the N1 deep-offset wall line. Tests pin this low so the + /// N1→S1 fallback can be exercised without 50k rows of fixture data; + /// production uses the `N1_DEEP_OFFSET_SAFE` default. + pub fn with_n1_deep_offset_safe(mut self, n: u32) -> Self { + self.n1_deep_offset_safe = n; + self + } + + /// Disable real sleep between backoff attempts. Tests pin this so + /// `503 → success on retry` exercises the retry loop in + /// milliseconds instead of seconds. Production code leaves it on. + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + /// C11 — bulk crawl parallelism from the runtime playback hint. + pub fn with_parallelism_budget(mut self, budget: ParallelismBudget) -> Self { + self.parallelism = budget; + self + } + + fn parallelism_budget(&self) -> ParallelismBudget { + self.parallelism + } + + /// IS-1 → IS-6. Resumes from `sync_state.initial_sync_cursor_json` + /// when a cursor is already persisted; otherwise picks a strategy + /// from `capability_flags` and starts fresh. + pub async fn run(&self) -> Result { + let sync_state = SyncStateRepository::new(self.store); + sync_state + .ensure(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + + // IS-1 — phase=initial_sync. + sync_state + .set_sync_phase(&self.server_id, &self.library_scope, "initial_sync") + .map_err(SyncError::Storage)?; + self.progress.emit(ProgressEvent::PhaseChanged { + phase: "initial_sync".into(), + }); + + let mut cursor = self.load_or_init_cursor(&sync_state)?; + let mut report = InitialSyncReport { + strategy: Some(cursor.strategy.clone()), + ingested_count: cursor.ingested_count, + remapped_count: 0, + }; + let strategy = IngestStrategy::from_tag(&cursor.strategy).ok_or_else(|| { + SyncError::CursorIncompatible { + expected: "n1|s1|s2|s3", + actual: cursor.strategy.clone(), + } + })?; + + // IS-3 — bulk ingest per strategy. + if cursor.phase == CursorPhase::Ingest { + self.store.set_bulk_ingest_active(true); + self.store + .with_conn_mut("bulk.begin", |conn| { + suspend_track_fts_triggers(conn)?; + suspend_track_secondary_indexes(conn)?; + conn.pragma_update(None, "synchronous", "OFF")?; + conn.pragma_update(None, "wal_autocheckpoint", 0)?; + conn.pragma_update(None, "cache_size", -128_000)?; + Ok(()) + }) + .map_err(SyncError::Storage)?; + crate::app_eprintln!( + "[library-sync] IS-3 bulk ingest: FTS/indexes suspended, sync=OFF" + ); + let _bulk = BulkIngestGuard { store: self.store }; + + match strategy { + IngestStrategy::N1 => self.run_n1(&mut cursor, &mut report, &sync_state).await?, + IngestStrategy::S1 => self.run_s1(&mut cursor, &mut report, &sync_state).await?, + IngestStrategy::S2 => self.run_s2(&mut cursor, &mut report, &sync_state).await?, + IngestStrategy::S3 => { + return Err(SyncError::StrategyUnsupported { strategy: "s3" }) + } + } + self.link_canonical_after_bulk_ingest()?; + cursor.phase = CursorPhase::ArtistPass; + self.persist_cursor(&sync_state, &cursor)?; + } + + // IS-4 — optional artist/album index pass via `getArtists`. + if cursor.phase == CursorPhase::ArtistPass { + self.run_artist_pass(&sync_state).await?; + cursor.phase = CursorPhase::Watermarks; + self.persist_cursor(&sync_state, &cursor)?; + } + + // IS-5 — watermarks (server_last_scan_iso, server_track_count, + // artists_last_modified_ms) so DS-0 polls can short-circuit. + if cursor.phase == CursorPhase::Watermarks { + self.run_watermark_pass(&sync_state).await?; + cursor.phase = CursorPhase::Done; + self.persist_cursor(&sync_state, &cursor)?; + } + + // IS-6 — phase=ready, clear cursor, stamp watermarks. + let finished_at = now_unix_ms(); + let local_count = crate::dto::count_local_tracks(self.store, &self.server_id) + .map_err(SyncError::Storage)?; + sync_state + .set_local_track_count(&self.server_id, &self.library_scope, local_count) + .map_err(SyncError::Storage)?; + sync_state + .set_last_full_sync_at(&self.server_id, &self.library_scope, finished_at) + .map_err(SyncError::Storage)?; + sync_state + .set_sync_phase(&self.server_id, &self.library_scope, "ready") + .map_err(SyncError::Storage)?; + sync_state + .set_initial_sync_cursor( + &self.server_id, + &self.library_scope, + &Value::Object(serde_json::Map::new()), + ) + .map_err(SyncError::Storage)?; + self.progress.emit(ProgressEvent::Completed { + kind: "initial_sync".into(), + }); + + Ok(report) + } + + // ── cursor / persistence ─────────────────────────────────────────── + + fn load_or_init_cursor( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result { + let raw = sync_state + .get_initial_sync_cursor(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + // R7-15 Q4: pick with the large-library policy, not just the cap + // flags. `server_track_count` (probe `getScanStatus` count or a prior + // watermark) and the learned `n1_bulk_unreliable` flag steer large + // catalogs onto S1 instead of N1's deep-offset wall. + let server_track_count = sync_state + .get_server_track_count(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + let n1_bulk_unreliable = sync_state + .get_n1_bulk_unreliable(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)? + .unwrap_or(false); + let selected_strategy = IngestStrategy::select_initial_strategy( + self.capability_flags, + server_track_count, + n1_bulk_unreliable, + ); + if let Some(raw) = raw { + if !is_empty_cursor(&raw) { + match serde_json::from_value::(raw) { + Ok(parsed) => { + let has_progress = parsed.ingested_count > 0 + || parsed.phase != CursorPhase::Ingest; + // R7-15 Q3: freeze the in-flight strategy on resume. + // Once a run has made progress, a re-probe that now + // picks a different strategy (the Navidrome bearer + // flapped, or the large-library gate resolves + // differently) must NOT reset the cursor — that + // restarts ingest from offset 0 on every launch, which + // is exactly why large syncs never completed. Resume + // under the cursor's own strategy and ignore the + // probe's pick. Exception: a cursor still on N1 after + // the server was learned `n1_bulk_unreliable` is + // known-broken — fall through and re-select (the + // mid-run N1→S1 fallback normally rewrites such a + // cursor in place, preserving progress). + let frozen_strategy_known_broken = n1_bulk_unreliable + && parsed.strategy == IngestStrategy::N1.as_tag(); + if has_progress && !frozen_strategy_known_broken { + return Ok(parsed); + } + // No resumable progress (offset 0) or a known-broken + // N1 cursor: adopting the freshly-selected strategy + // costs nothing, so take it. Re-ingest is idempotent + // (upsert) and the tombstone pass reconciles leftovers. + if parsed.strategy == selected_strategy.as_tag() { + return Ok(parsed); + } + crate::app_eprintln!( + "[library-sync] re-selecting initial-sync strategy for server \ + `{}`: was `{}` (no resumable progress), now `{}`", + self.server_id, + parsed.strategy, + selected_strategy.as_tag() + ); + } + Err(e) => { + // A corrupt/unreadable cursor can't drive resume; reset + // rather than hard-error (which would brick every future + // sync with no UI recovery path). + crate::app_eprintln!( + "[library-sync] resetting unreadable initial-sync cursor for \ + server `{}` ({e}); starting fresh", + self.server_id + ); + } + } + } + } + let scope = if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.clone()) + }; + let fresh = InitialSyncCursor::fresh(selected_strategy, scope); + self.persist_cursor(sync_state, &fresh)?; + Ok(fresh) + } + + fn persist_cursor( + &self, + sync_state: &SyncStateRepository<'_>, + cursor: &InitialSyncCursor, + ) -> Result<(), SyncError> { + let value = serde_json::to_value(cursor) + .map_err(|e| SyncError::Storage(format!("serialize cursor: {e}")))?; + sync_state + .set_initial_sync_cursor_and_local_track_count( + &self.server_id, + &self.library_scope, + &value, + i64::from(cursor.ingested_count), + ) + .map_err(SyncError::Storage) + } + + fn check_cancellation(&self) -> Result<(), SyncError> { + if let Some(flag) = &self.cancel { + if flag.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + } + Ok(()) + } + + fn library_scope_opt(&self) -> Option<&str> { + if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.as_str()) + } + } + + async fn sleep(&self, d: Duration) { + if self.sleep_enabled && !d.is_zero() { + tokio::time::sleep(d).await; + } + } + + fn write_batch_timed(&self, rows: &[TrackRow]) -> Result { + TrackRepository::new(self.store) + .upsert_batch_initial_ingest_timed(rows) + .map_err(SyncError::Storage) + } + + fn write_batch_logged( + &self, + rows: &[TrackRow], + label: &str, + offset: u32, + ) -> Result<(RemapStats, WriteOpTiming), SyncError> { + let timing = self.write_batch_timed(rows)?; + let total_ms = timing.total_ms(); + if total_ms >= 500 { + crate::app_eprintln!( + "[library-sync] {label} offset={offset} rows={} write_ms={total_ms} lock_wait_ms={} sql_exec_ms={} (slow batch)", + rows.len(), + timing.lock_wait_ms, + timing.exec_ms, + ); + } else { + crate::app_eprintln!( + "[library-sync] {label} offset={offset} rows={} write_ms={total_ms} lock_wait_ms={} sql_exec_ms={}", + rows.len(), + timing.lock_wait_ms, + timing.exec_ms, + ); + } + Ok((RemapStats::default(), timing)) + } + + fn link_canonical_after_bulk_ingest(&self) -> Result<(), SyncError> { + let start = std::time::Instant::now(); + let linked = crate::canonical::link_all_tracks_for_server( + self.store, + &self.server_id, + now_unix_ms(), + ) + .map_err(SyncError::Storage)?; + crate::app_eprintln!( + "[library-sync] canonical bulk link server `{}`: {linked} tracks in {}ms", + self.server_id, + start.elapsed().as_millis() + ); + Ok(()) + } + + // ── N1 (Navidrome native /api/song) ──────────────────────────────── + + async fn run_n1( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let creds = self.navidrome.as_ref().ok_or_else(|| SyncError::Transport( + "n1 strategy selected but no Navidrome credentials supplied".into(), + ))?; + let mut offset = match cursor.strategy_state { + StrategyState::LinearOffset { offset } => offset, + ref other => { + return Err(SyncError::Storage(format!( + "n1 expected linear-offset cursor, got {other:?}" + ))) + } + }; + + let budget = self.parallelism_budget(); + let prefetch = linear_prefetch_depth(&budget); + crate::app_eprintln!( + "[library-sync] N1 ingest server `{}`: prefetch_depth={} max_concurrent={} batch_size={}", + self.server_id, + prefetch, + budget.max_concurrent, + self.batch_size + ); + let mut batch_count: u32 = 0; + if prefetch <= 1 { + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + sleep_request_gap(&budget, self.sleep_enabled).await; + let array = match self.fetch_n1_page(creds, offset).await { + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return self.fall_back_n1_to_s1(cursor, report, sync_state).await; + } + other => other?, + }; + if array.is_empty() { + break; + } + offset = self + .ingest_n1_page( + &array, + offset, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (array.len() as u32) < self.batch_size, + }, + ) + .await?; + if (array.len() as u32) < self.batch_size { + break; + } + } + self.persist_cursor(sync_state, cursor)?; + return Ok(()); + } + + let batch_size = self.batch_size; + let cancel = self.cancel.clone(); + let sleep_enabled = self.sleep_enabled; + let creds = creds.clone(); + let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset); + + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + + queue.pump(|| self.check_cancellation(), |off| { + let creds = creds.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + retry_fetch( + sleep_enabled, + || check_cancel_flag(&cancel), + || async { + let end = off.saturating_add(batch_size); + let response = nd_list_songs_internal( + &creds.server_url, + &creds.bearer_token, + "id", + "ASC", + off, + end, + ) + .await + .map_err(SyncError::Navidrome)?; + Ok(response.as_array().cloned().unwrap_or_default()) + }, + |e| e, + ) + .await + }) + })?; + + let array = match queue + .take_at(offset, || self.check_cancellation()) + .await + { + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return self.fall_back_n1_to_s1(cursor, report, sync_state).await; + } + Err(e) => return Err(e), + Ok(Some(page)) => page, + Ok(None) => { + sleep_request_gap(&budget, self.sleep_enabled).await; + match self.fetch_n1_page(&creds, offset).await { + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return self.fall_back_n1_to_s1(cursor, report, sync_state).await; + } + other => other?, + } + } + }; + + if array.is_empty() { + break; + } + + offset = self + .ingest_n1_page( + &array, + offset, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (array.len() as u32) < self.batch_size, + }, + ) + .await?; + + if (array.len() as u32) < self.batch_size { + queue.mark_exhausted(); + break; + } + } + self.persist_cursor(sync_state, cursor)?; + Ok(()) + } + + async fn fetch_n1_page( + &self, + creds: &NavidromeProbeCredentials, + offset: u32, + ) -> Result, SyncError> { + let end = offset.saturating_add(self.batch_size); + let response = match retry_with_backoff( + self, + || { + nd_list_songs_internal( + &creds.server_url, + &creds.bearer_token, + "id", + "ASC", + offset, + end, + ) + }, + SyncError::Navidrome, + ) + .await + { + Ok(v) => v, + Err(e) if self.n1_hit_deep_offset_wall(&e, offset) => { + return Err(e); + } + Err(e) => return Err(e), + }; + Ok(response.as_array().cloned().unwrap_or_default()) + } + + async fn ingest_n1_page( + &self, + array: &[Value], + offset: u32, + ctx: &mut IngestPageCtx<'_>, + ) -> Result { + let synced_at = now_unix_ms(); + let rows: Vec = array + .iter() + .filter_map(|v| { + navidrome_song_to_track_row( + &self.server_id, + v, + synced_at, + self.library_scope_opt(), + ) + }) + .collect(); + let (_stats, _timing) = self.write_batch_logged(&rows, "N1", offset)?; + ctx.report.ingested_count = ctx.report.ingested_count.saturating_add(rows.len() as u32); + + let next_offset = offset.saturating_add(self.batch_size); + ctx.cursor.strategy_state = StrategyState::LinearOffset { + offset: next_offset, + }; + ctx.cursor.ingested_count = ctx.report.ingested_count; + *ctx.batch_count += 1; + if ctx.force_persist || ctx.batch_count.is_multiple_of(CURSOR_PERSIST_EVERY_BATCHES) { + self.persist_cursor(ctx.sync_state, ctx.cursor)?; + } + self.progress.emit(ProgressEvent::IngestPage { + ingested_total: ctx.report.ingested_count, + batch_count: *ctx.batch_count, + metrics: None, + }); + Ok(next_offset) + } + + /// True when an N1 error is the deep-offset wall: a persistent HTTP 500 + /// at or beyond the safety line (R7-15 Q5). A 500 at a shallow offset is + /// a different failure and propagates as an error instead. + fn n1_hit_deep_offset_wall(&self, e: &SyncError, offset: u32) -> bool { + offset >= self.n1_deep_offset_safe && e.navidrome_http_status() == Some(500) + } + + /// R7-15 Q5 — one-way N1→S1 fallback. Learn `n1_bulk_unreliable` for this + /// server, then restart ingest on S1. N1 (`id ASC`) and S1 (`search3` + /// default order) don't share an offset space, so resuming from the N1 + /// offset would skip songs — restart S1 from 0. Re-ingest is idempotent + /// (PK upsert); the duplicate work over rows N1 already wrote is + /// acceptable for v1. The cursor is rewritten in place, never zeroed away. + async fn fall_back_n1_to_s1( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + crate::app_eprintln!( + "[library-sync] N1 hit the deep-offset wall for server `{}`; flagging \ + n1_bulk_unreliable and falling back to S1", + self.server_id + ); + sync_state + .set_n1_bulk_unreliable(&self.server_id, &self.library_scope, true) + .map_err(SyncError::Storage)?; + let scope = if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.clone()) + }; + *cursor = InitialSyncCursor::fresh(IngestStrategy::S1, scope); + report.ingested_count = 0; + report.strategy = Some(cursor.strategy.clone()); + self.persist_cursor(sync_state, cursor)?; + self.run_s1(cursor, report, sync_state).await + } + + // ── S1 (Subsonic search3 empty query) ────────────────────────────── + + async fn run_s1( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let mut offset = match cursor.strategy_state { + StrategyState::LinearOffset { offset } => offset, + ref other => { + return Err(SyncError::Storage(format!( + "s1 expected linear-offset cursor, got {other:?}" + ))) + } + }; + + let budget = self.parallelism_budget(); + let prefetch = linear_prefetch_depth(&budget); + crate::app_eprintln!( + "[library-sync] S1 ingest server `{}`: prefetch_depth={} max_concurrent={} batch_size={}", + self.server_id, + prefetch, + budget.max_concurrent, + self.batch_size + ); + let mut batch_count: u32 = 0; + if prefetch <= 1 { + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + sleep_request_gap(&budget, self.sleep_enabled).await; + let fetch_start = std::time::Instant::now(); + let (result, raw_body) = match self.fetch_s1_page(offset).await { + Err(e) if is_fetch_failure(&e) => { + return self.fall_back_s1_to_s2(cursor, report, sync_state).await; + } + other => other?, + }; + let fetch_ms = fetch_start.elapsed().as_millis() as u32; + if result.song.is_empty() { + break; + } + offset = self + .ingest_s1_page( + &result, + &raw_body, + offset, + fetch_ms, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (result.song.len() as u32) < self.batch_size, + }, + ) + .await?; + if (result.song.len() as u32) < self.batch_size { + break; + } + } + self.persist_cursor(sync_state, cursor)?; + return Ok(()); + } + + let batch_size = self.batch_size; + let subsonic = self.subsonic.clone(); + let library_scope = self.library_scope.clone(); + let cancel = self.cancel.clone(); + let sleep_enabled = self.sleep_enabled; + let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset); + + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + + queue.pump(|| self.check_cancellation(), |off| { + let subsonic = subsonic.clone(); + let library_scope = library_scope.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + retry_fetch( + sleep_enabled, + || check_cancel_flag(&cancel), + || async { + let scope = if library_scope.is_empty() { + None + } else { + Some(library_scope.as_str()) + }; + subsonic + .search3_with_raw("", batch_size, off, scope) + .await + .map_err(SyncError::from) + }, + |e| e, + ) + .await + }) + })?; + + let fetch_start = std::time::Instant::now(); + let (result, raw_body) = match queue + .take_at(offset, || self.check_cancellation()) + .await + { + Err(e) if is_fetch_failure(&e) => { + return self.fall_back_s1_to_s2(cursor, report, sync_state).await; + } + Err(e) => return Err(e), + Ok(Some(page)) => page, + Ok(None) => { + sleep_request_gap(&budget, self.sleep_enabled).await; + match self.fetch_s1_page(offset).await { + Err(e) if is_fetch_failure(&e) => { + return self.fall_back_s1_to_s2(cursor, report, sync_state).await; + } + other => other?, + } + } + }; + let fetch_ms = fetch_start.elapsed().as_millis() as u32; + + if result.song.is_empty() { + break; + } + + offset = self + .ingest_s1_page( + &result, + &raw_body, + offset, + fetch_ms, + &mut IngestPageCtx { + cursor, + report, + sync_state, + batch_count: &mut batch_count, + force_persist: (result.song.len() as u32) < self.batch_size, + }, + ) + .await?; + + if (result.song.len() as u32) < self.batch_size { + queue.mark_exhausted(); + break; + } + } + self.persist_cursor(sync_state, cursor)?; + Ok(()) + } + + async fn fetch_s1_page( + &self, + offset: u32, + ) -> Result<(psysonic_integration::subsonic::SearchResult, Value), SyncError> { + let scope = self.library_scope_opt(); + retry_with_backoff( + self, + || self.subsonic.search3_with_raw("", self.batch_size, offset, scope), + SyncError::from, + ) + .await + } + + async fn ingest_s1_page( + &self, + result: &psysonic_integration::subsonic::SearchResult, + raw_body: &Value, + offset: u32, + fetch_ms: u32, + ctx: &mut IngestPageCtx<'_>, + ) -> Result { + let raw_songs = raw_body + .get("song") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let synced_at = now_unix_ms(); + let mut rows: Vec = Vec::with_capacity(result.song.len()); + for (i, song) in result.song.iter().enumerate() { + let raw = raw_songs + .get(i) + .cloned() + .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); + rows.push(subsonic_song_to_track_row( + &self.server_id, + song, + &raw, + synced_at, + self.library_scope_opt(), + )); + } + let row_count = rows.len() as u32; + let (_stats, write_timing) = self.write_batch_logged(&rows, "S1", offset)?; + ctx.report.ingested_count = ctx.report.ingested_count.saturating_add(row_count); + + let next_offset = offset.saturating_add(self.batch_size); + ctx.cursor.strategy_state = StrategyState::LinearOffset { + offset: next_offset, + }; + ctx.cursor.ingested_count = ctx.report.ingested_count; + *ctx.batch_count += 1; + let persist_start = std::time::Instant::now(); + let did_persist = + ctx.force_persist || ctx.batch_count.is_multiple_of(CURSOR_PERSIST_EVERY_BATCHES); + if did_persist { + self.persist_cursor(ctx.sync_state, ctx.cursor)?; + } + let persist_ms = if did_persist { + persist_start.elapsed().as_millis() as u32 + } else { + 0 + }; + self.progress.emit(ProgressEvent::IngestPage { + ingested_total: ctx.report.ingested_count, + batch_count: *ctx.batch_count, + metrics: Some(IngestBatchMetrics { + offset, + strategy: "s1".into(), + fetch_ms, + write_ms: write_timing.total_ms() as u32, + lock_wait_ms: write_timing.lock_wait_ms as u32, + sql_exec_ms: write_timing.exec_ms as u32, + persist_ms, + row_count, + bulk_ingest_active: self.store.bulk_ingest_active(), + }), + }); + Ok(next_offset) + } + + /// Q8 (R7-15) — fall back to the universal S2 album crawl when S1 fails + /// persistently. S1 (`search3` order) and S2 (album-list order) don't + /// share an offset space, so restart S2 from scratch; re-ingest is + /// idempotent (PK upsert). The cursor is rewritten in place, never zeroed. + /// No new artist-walk strategy is introduced (Q8 decision). + async fn fall_back_s1_to_s2( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + crate::app_eprintln!( + "[library-sync] S1 failed persistently for server `{}`; falling back to \ + S2 album crawl", + self.server_id + ); + let scope = if self.library_scope.is_empty() { + None + } else { + Some(self.library_scope.clone()) + }; + *cursor = InitialSyncCursor::fresh(IngestStrategy::S2, scope); + report.ingested_count = 0; + report.strategy = Some(cursor.strategy.clone()); + self.persist_cursor(sync_state, cursor)?; + self.run_s2(cursor, report, sync_state).await + } + + // ── S2 (album crawl: getAlbumList2 + getAlbum) ───────────────────── + + async fn run_s2( + &self, + cursor: &mut InitialSyncCursor, + report: &mut InitialSyncReport, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let (mut album_offset, resume_album_id) = match &cursor.strategy_state { + StrategyState::AlbumCrawl { album_offset, current_album_id } => { + (*album_offset, current_album_id.clone()) + } + ref other => { + return Err(SyncError::Storage(format!( + "s2 expected album-crawl cursor, got {other:?}" + ))) + } + }; + + let budget = self.parallelism_budget(); + crate::app_eprintln!( + "[library-sync] S2 ingest server `{}`: parallel_get_album={} batch_size={}", + self.server_id, + budget.max_concurrent, + self.batch_size + ); + let mut batch_count: u32 = 0; + let mut resume_from = resume_album_id; + + loop { + wait_while_bulk_paused(&budget, self.sleep_enabled, || self.check_cancellation()) + .await?; + self.check_cancellation()?; + let scope = self.library_scope_opt(); + sleep_request_gap(&budget, self.sleep_enabled).await; + let albums = retry_with_backoff( + self, + || { + self.subsonic.get_album_list2( + "alphabeticalByName", + self.batch_size, + album_offset, + scope, + ) + }, + SyncError::from, + ) + .await?; + if albums.is_empty() { + break; + } + + let mut album_ids: Vec = Vec::with_capacity(albums.len()); + if let Some(ref resume_after) = resume_from { + let mut past_resume = false; + for album_summary in &albums { + if !past_resume { + if resume_after == &album_summary.id { + past_resume = true; + } + continue; + } + album_ids.push(album_summary.id.clone()); + } + } else { + for album_summary in &albums { + album_ids.push(album_summary.id.clone()); + } + } + resume_from = None; + + let fetched = fetch_albums_parallel( + self.subsonic, + &album_ids, + ParallelAlbumFetchOpts { + budget, + sleep_enabled: self.sleep_enabled, + cancel: self.cancel.clone(), + }, + ) + .await?; + + for (album, raw_album) in fetched { + self.check_cancellation()?; + let synced_at = now_unix_ms(); + let raw_songs = raw_album + .get("song") + .and_then(|s| s.as_array()) + .cloned() + .unwrap_or_default(); + let mut rows: Vec = Vec::with_capacity(album.song.len()); + for (i, song) in album.song.iter().enumerate() { + let raw = raw_songs + .get(i) + .cloned() + .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); + rows.push(subsonic_song_to_track_row( + &self.server_id, + song, + &raw, + synced_at, + self.library_scope_opt(), + )); + } + if !rows.is_empty() { + let (_stats, _timing) = self.write_batch_logged(&rows, "S2", album_offset)?; + report.ingested_count = report + .ingested_count + .saturating_add(rows.len() as u32); + batch_count += 1; + self.progress.emit(ProgressEvent::IngestPage { + ingested_total: report.ingested_count, + batch_count, + metrics: None, + }); + } + cursor.strategy_state = StrategyState::AlbumCrawl { + album_offset, + current_album_id: Some(album.id.clone()), + }; + cursor.ingested_count = report.ingested_count; + self.persist_cursor(sync_state, cursor)?; + } + + album_offset = album_offset.saturating_add(self.batch_size); + cursor.strategy_state = StrategyState::AlbumCrawl { + album_offset, + current_album_id: None, + }; + cursor.ingested_count = report.ingested_count; + self.persist_cursor(sync_state, cursor)?; + + if (albums.len() as u32) < self.batch_size { + break; + } + } + Ok(()) + } + + // ── IS-4 artist pass (best-effort browse acceleration) ───────────── + + async fn run_artist_pass( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + let scope = self.library_scope_opt(); + let artists = retry_with_backoff( + self, + || self.subsonic.get_artists(scope), + SyncError::from, + ) + .await + .ok(); + if let Some(index) = artists { + if let Some(ms) = index.last_modified_ms { + sync_state + .set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms) + .map_err(SyncError::Storage)?; + } + } + Ok(()) + } + + // ── IS-5 watermarks ──────────────────────────────────────────────── + + async fn run_watermark_pass( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result<(), SyncError> { + if self + .capability_flags + .contains(CapabilityFlags::SCAN_STATUS_AVAILABLE) + { + if let Ok(s) = self.subsonic.get_scan_status().await { + sync_state + .set_server_last_scan_iso( + &self.server_id, + &self.library_scope, + s.last_scan.as_deref(), + ) + .map_err(SyncError::Storage)?; + } + } + Ok(()) + } +} + +fn is_empty_cursor(v: &Value) -> bool { + matches!(v, Value::Object(o) if o.is_empty()) +} + +fn now_unix_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +/// Wrap an async closure in §6.8 backoff. Retries on `SyncError::Transport` +/// up to `MAX_ATTEMPTS_PER_BATCH`, sleeping per the backoff schedule +/// (skipped when `sleep_enabled` is false — test path). +/// Cancellation is checked between attempts. +async fn retry_with_backoff<'a, F, FFut, T, E>( + runner: &InitialSyncRunner<'a>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> FFut, + FFut: std::future::Future>, +{ + let mut backoff = Backoff::default(); + let mut attempt = 0u32; + loop { + runner.check_cancellation()?; + attempt += 1; + match build().await { + Ok(v) => return Ok(v), + Err(e) => { + let mapped = map_err(e); + if !is_retryable(&mapped) || attempt >= MAX_ATTEMPTS_PER_BATCH { + return Err(mapped); + } + let delay = backoff.next_delay(); + let jittered = with_jitter(delay, jitter_salt(attempt)); + runner.sleep(jittered).await; + } + } + } +} + +fn is_retryable(e: &SyncError) -> bool { + matches!( + e, + SyncError::Transport(_) | SyncError::Navidrome(_) + ) +} + +/// A persistent fetch failure (network / HTTP / decode / API) that warrants +/// switching ingest strategy (Q8 S1→S2). Cancellation is user intent and +/// storage is a local problem a strategy switch can't fix — both propagate. +fn is_fetch_failure(e: &SyncError) -> bool { + matches!( + e, + SyncError::Transport(_) + | SyncError::Subsonic { .. } + | SyncError::Navidrome(_) + | SyncError::NotFound + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::capability::NavidromeProbeCredentials; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use std::sync::Arc; + use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn flags(bits: u32) -> CapabilityFlags { + CapabilityFlags::new(bits) + } + + fn test_subsonic(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + async fn mount_search3_pages(server: &MockServer, total: u32, batch: u32) { + // Two-page test fixture: first page returns `batch` songs, + // second page returns the remainder, third page returns empty. + for page in 0u32..=2 { + let offset = page * batch; + let body = if offset >= total { + json!({ "subsonic-response": { "status": "ok", "searchResult3": {} } }) + } else { + let remaining = (total - offset).min(batch); + let songs: Vec<_> = (0..remaining) + .map(|i| json!({ + "id": format!("tr_{:04}", offset + i), + "title": format!("Title {}", offset + i), + "duration": 200_i64 + (offset + i) as i64, + })) + .collect(); + json!({ + "subsonic-response": { + "status": "ok", + "searchResult3": { "song": songs } + } + }) + }; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("songOffset", offset.to_string())) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(server) + .await; + } + } + + async fn mount_minimal_artists(server: &MockServer) { + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": 1_716_840_000_000_i64, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "scanStatus": { + "scanning": false, + "count": 1234, + "lastScan": "2024-06-01T12:00:00Z" + } + } + }))) + .mount(server) + .await; + } + + // ── S1 happy path ────────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_ingest_drains_pages_and_persists_done_phase() { + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 7, /*batch*/ 4).await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_batch_size(4) + .with_sleep_disabled(); + + let report = runner.run().await.unwrap(); + assert_eq!(report.ingested_count, 7); + assert_eq!(report.remapped_count, 0); + assert_eq!(report.strategy.as_deref(), Some("s1")); + + // sync_phase ended in "ready" and cursor cleared. + let sync_state = SyncStateRepository::new(&store); + assert_eq!( + sync_state.get_sync_phase("s1", "").unwrap().as_deref(), + Some("ready") + ); + let cur = sync_state.get_initial_sync_cursor("s1", "").unwrap(); + assert_eq!(cur, Some(json!({}))); + + // Tracks landed in the store. + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 7); + } + + // ── Per-batch progress is emitted during ingest ─────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn initial_sync_emits_per_batch_progress() { + use crate::sync::progress::ChannelProgress; + use std::time::Duration; + + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 7, /*batch*/ 4).await; + mount_minimal_artists(&server).await; + + // ZERO interval so the throttle never drops a batch event in the test. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let progress: Arc = + Arc::new(ChannelProgress::with_interval(tx, Duration::ZERO)); + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_batch_size(4) + .with_sleep_disabled() + .with_progress(progress) + .run() + .await + .unwrap(); + + // Collect the per-batch ingest totals the runner emitted. + let mut totals = Vec::new(); + while let Ok(ev) = rx.try_recv() { + if let ProgressEvent::IngestPage { ingested_total, .. } = ev { + totals.push(ingested_total); + } + } + assert!(!totals.is_empty(), "initial sync must emit per-batch IngestPage progress"); + assert_eq!(*totals.last().unwrap(), 7, "final progress total must reach the full count"); + assert!( + totals.windows(2).all(|w| w[0] <= w[1]), + "ingest totals must be non-decreasing" + ); + } + + // ── S1 mid-cursor resume ────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_resumes_from_persisted_cursor_after_kill() { + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 10, /*batch*/ 4).await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + + // Seed the cursor as if a prior run completed page 0 (offset=4) + // but was killed before page 1 landed. + sync_state.ensure("s1", "").unwrap(); + let mid_cursor = json!({ + "strategy": "s1", + "phase": "ingest", + "library_scope": null, + "ingested_count": 4, + "strategy_state": { "kind": "linear_offset", "offset": 4 } + }); + sync_state + .set_initial_sync_cursor("s1", "", &mid_cursor) + .unwrap(); + + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(4) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + // Resumed at offset 4 — only 6 more rows ingested. + assert_eq!(report.ingested_count, 4 + 6); + // …but the store ends up with all 10. + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + // 6 — only the pages run by *this* invocation are persisted to + // `track` here because the cursor said offset=4 but the prior + // run never actually wrote rows in this fixture. The assertion + // documents the resume semantics: cursor controls request + // offset, not row count. + assert_eq!(count, 6); + } + + // ── Stale / unreadable cursor self-heals instead of bricking ────── + + #[test] + fn cursor_with_progress_resumes_and_ignores_reselected_strategy() { + // R7-15 Q3: a cursor that already made progress must resume under its + // own strategy even when a re-probe would now pick a different one + // (here: flags advertise N1 again, but the in-flight cursor is S1). + // Freezing the strategy is what stops the flapping-induced restart + // from offset 0 that kept large syncs from ever completing. + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "s1", + "phase": "ingest", + "ingested_count": 42, + "strategy_state": { "kind": "linear_offset", "offset": 2000 } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1", "in-flight strategy must be frozen on resume"); + assert_eq!(cursor.ingested_count, 42, "resume must preserve progress"); + } + + #[test] + fn fresh_cursor_without_progress_adopts_reselected_strategy() { + // No progress yet (offset 0): adopting the freshly-selected strategy + // is free, so a cursor written under a now-unavailable strategy is + // re-selected (not a hard error, not a needless resume). + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "n1", + "phase": "ingest", + "ingested_count": 0, + "strategy_state": { "kind": "linear_offset", "offset": 0 } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1", "no-progress cursor adopts the selected strategy"); + assert_eq!(cursor.ingested_count, 0); + } + + #[test] + fn n1_cursor_with_progress_reselects_when_flagged_unreliable() { + // A cursor still on N1 after the server was learned `n1_bulk_unreliable` + // is known-broken: the freeze does not apply, so it re-selects onto the + // non-N1 strategy rather than resuming a wall-bound N1 loop. (The + // mid-run N1→S1 fallback normally rewrites such a cursor in place, + // preserving progress; this is the defensive fallback.) + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_n1_bulk_unreliable("s1", "", true).unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "n1", + "phase": "ingest", + "ingested_count": 42, + "strategy_state": { "kind": "linear_offset", "offset": 500 } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1", "known-broken N1 cursor must re-select to S1"); + assert_eq!(cursor.ingested_count, 0); + } + + #[test] + fn unreadable_cursor_is_reset_not_errored() { + // A corrupt cursor (missing the required `strategy` field) must + // also self-heal to a fresh cursor rather than error out. + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor("s1", "", &json!({ "phase": "ingest", "ingested_count": 9 })) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + let runner = InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s1"); + } + + // ── Backoff retries on 503 then succeeds ────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_retries_after_transient_503_then_succeeds() { + let server = MockServer::start().await; + // First request — 503. Wiremock `up_to_n_times` makes this + // simple: 1 mock that only answers once with 503, then a + // catch-all that returns the empty success envelope. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("songOffset", "0")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": {} } + }))) + .mount(&server) + .await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + assert_eq!(report.ingested_count, 0, "all retries land before a song"); + } + + // ── Cancellation token aborts mid-run ───────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn cancellation_flag_returns_cancelled_error() { + let server = MockServer::start().await; + mount_search3_pages(&server, /*total*/ 100, /*batch*/ 4).await; + let cancel = Arc::new(AtomicBool::new(true)); // already tripped + let store = LibraryStore::open_in_memory(); + + let err = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(4) + .with_cancellation(cancel) + .with_sleep_disabled() + .run() + .await + .unwrap_err(); + assert!(matches!(err, SyncError::Cancelled)); + } + + // ── N1 happy path via wiremock ──────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn n1_ingest_paginates_navidrome_native_endpoint() { + let server = MockServer::start().await; + // Two pages of 2 songs each, then empty. + for page in 0u32..=2 { + let start = page * 2; + let songs = if page < 2 { + vec![ + json!({"id": format!("tr_{start}"), "title": format!("t{start}"), "duration": 100}), + json!({"id": format!("tr_{}", start + 1), "title": format!("t{}", start + 1), "duration": 100}), + ] + } else { + vec![] + }; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", start.to_string())) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Array(songs))) + .mount(&server) + .await; + } + // Minimal Subsonic ping path for artist/watermark phases. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { "lastModified": 0, "ignoredArticles": "", "index": [] } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getScanStatus.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "scanStatus": { "scanning": false } } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE), + ) + .with_navidrome_credentials(nav) + .with_batch_size(2) + .with_sleep_disabled() + .run() + .await + .unwrap(); + assert_eq!(report.ingested_count, 4); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 4); + + let sync_state = SyncStateRepository::new(&store); + assert_eq!(sync_state.get_local_track_count("s1", "").unwrap(), Some(4)); + assert_eq!(sync_state.get_sync_phase("s1", "").unwrap().as_deref(), Some("ready")); + let full_sync: Option = store + .with_conn("misc", |c| { + c.query_row( + "SELECT last_full_sync_at FROM sync_state WHERE server_id = 's1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert!(full_sync.is_some()); + } + + // ── N1 → S1 deep-offset fallback (R7-15 Q5) ─────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn n1_deep_offset_500_falls_back_to_s1_and_flags_server() { + let server = MockServer::start().await; + // N1 serves the first page, then 500s at the (test-lowered) wall. + // Ids match the S1 fixture format so the re-ingest upserts rather + // than duplicating the rows N1 already wrote. + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "0")) + .and(header("X-ND-Authorization", "Bearer nd-tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"id": "tr_0000", "title": "t0", "duration": 100}, + {"id": "tr_0001", "title": "t1", "duration": 100} + ]))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .and(query_param("_start", "2")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + // S1 restarts from offset 0 and ingests all 5 songs. + mount_search3_pages(&server, /*total*/ 5, /*batch*/ 2).await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags( + CapabilityFlags::NAVIDROME_NATIVE_BULK + | CapabilityFlags::SUBSONIC_SEARCH3_BULK + | CapabilityFlags::SCAN_STATUS_AVAILABLE, + ), + ) + .with_navidrome_credentials(nav) + .with_batch_size(2) + .with_n1_deep_offset_safe(2) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s1"), "run must finish on S1"); + // 5 distinct songs — N1's two rows were re-upserted, not duplicated. + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 5); + // Server learned the flag so future syncs skip N1. + let sync_state = SyncStateRepository::new(&store); + assert_eq!(sync_state.get_n1_bulk_unreliable("s1", "").unwrap(), Some(true)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn n1_shallow_500_propagates_without_fallback() { + // A 500 below the wall line is a real error, not the deep-offset + // trigger: it propagates and must NOT silently flag the server. + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/api/song")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + let nav = NavidromeProbeCredentials { + server_url: server.uri(), + bearer_token: "nd-tok".into(), + }; + let err = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::NAVIDROME_NATIVE_BULK), + ) + .with_navidrome_credentials(nav) + .with_batch_size(2) + .with_n1_deep_offset_safe(1000) + .with_sleep_disabled() + .run() + .await + .unwrap_err(); + assert!(matches!(err, SyncError::Navidrome(ref m) if m.contains("500"))); + let sync_state = SyncStateRepository::new(&store); + assert_eq!(sync_state.get_n1_bulk_unreliable("s1", "").unwrap(), Some(false)); + } + + // ── S1 → S2 persistent-failure fallback (R7-15 Q8) ──────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_persistent_failure_falls_back_to_s2() { + let server = MockServer::start().await; + // S1 (search3) fails on every attempt → persistent after retries. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + // S2 album crawl works: one album page, then empty. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [{ "id": "al_1", "name": "First" }] } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "albumList2": { "album": [] } } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", "al_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": "al_1", + "name": "First", + "song": [{ "id": "tr_a", "title": "song", "duration": 240 }] + } + } + }))) + .mount(&server) + .await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let report = InitialSyncRunner::new( + &store, + &test_subsonic(&server.uri()), + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(1) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s2"), "run must finish on S2"); + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 1, "the S2 album crawl ingested the track"); + } + + // ── S3 explicitly unsupported in v1 ─────────────────────────────── + + #[test] + fn s3_cursor_self_heals_to_selected_strategy() { + // S3 is never auto-selected, so a persisted s3 cursor (legacy / + // corrupt) can never match the chosen strategy — it must reset to + // the selected strategy rather than error. + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_initial_sync_cursor( + "s1", + "", + &json!({ + "strategy": "s3", + "phase": "ingest", + "ingested_count": 0, + "strategy_state": { "kind": "empty" } + }), + ) + .unwrap(); + + let subsonic = test_subsonic("http://127.0.0.1:1"); + // Default flags ⇒ selector resolves to s2. + let runner = InitialSyncRunner::new(&store, &subsonic, "s1", "", flags(0)); + let cursor = runner.load_or_init_cursor(&sync_state).unwrap(); + assert_eq!(cursor.strategy, "s2"); + } + + // ── S1 raw_json carries OpenSubsonic extensions verbatim ────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s1_ingest_preserves_open_subsonic_fields_in_track_raw_json() { + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .and(query_param("songOffset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "searchResult3": { + "song": [ + { + "id": "tr_1", + "title": "With Extensions", + "duration": 240, + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 }, + "contributors": [ + { "role": "producer", "artistId": "ar_9", "name": "Prod" } + ] + } + ] + } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/search3.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { "status": "ok", "searchResult3": {} } + }))) + .mount(&server) + .await; + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + InitialSyncRunner::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_batch_size(10) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + // raw_json column must contain the OpenSubsonic-only fields, + // not just the typed projection — ADR-7 fidelity. + let raw: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT raw_json FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(parsed.get("replayGain").is_some(), "raw json keeps replayGain"); + assert!(parsed.get("contributors").is_some(), "raw json keeps contributors"); + + // Typed projection also picked up replayGain via the mapping + // helper — both paths agree on the hot column. + let (rg_t, rg_a): (Option, Option) = store + .with_conn("misc", |c| { + c.query_row( + "SELECT replay_gain_track_db, replay_gain_album_db \ + FROM track WHERE server_id='s1' AND id='tr_1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + }) + .unwrap(); + assert_eq!(rg_t, Some(-1.2)); + assert_eq!(rg_a, Some(-0.8)); + } + + // ── S2 happy path: getAlbumList2 → getAlbum-per-id loop ─────────── + + #[tokio::test(flavor = "multi_thread")] + async fn s2_ingest_walks_albums_and_persists_songs() { + let server = MockServer::start().await; + // First album-list page: 2 albums, second page: 0 (loop ends). + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { + "album": [ + { "id": "al_1", "name": "First" }, + { "id": "al_2", "name": "Second" } + ] + } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .and(query_param("offset", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [] } + } + }))) + .mount(&server) + .await; + // Per-album song lists. + for (album_id, song_id) in [("al_1", "tr_a"), ("al_2", "tr_b")] { + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbum.view")) + .and(query_param("id", album_id)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "album": { + "id": album_id, + "name": album_id, + "song": [ + { "id": song_id, "title": "song", "duration": 240 } + ] + } + } + }))) + .mount(&server) + .await; + } + mount_minimal_artists(&server).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let report = InitialSyncRunner::new( + &store, + &subsonic, + "s2", + "", + // Force selector to fall through to S2: clear N1 + S1 bits. + flags(0), + ) + .with_batch_size(2) + .with_sleep_disabled() + .run() + .await + .unwrap(); + + assert_eq!(report.strategy.as_deref(), Some("s2")); + assert_eq!(report.ingested_count, 2); + + let count: i64 = store + .with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))) + .unwrap(); + assert_eq!(count, 2); + } + + // ── Remap path (§6.9) — exercised on delta / full upsert, not IS-3 bulk ─ + + #[test] + fn remap_fires_on_unstable_track_ids_batch_upsert() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + repo.upsert_batch(&[TrackRow { + server_id: "s1".into(), + id: "tr_old".into(), + title: "Aurora".into(), + title_sort: None, + artist: Some("A".into()), + artist_id: None, + album: "An Album".into(), + album_id: None, + album_artist: None, + duration_sec: 240, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: Some("/path/aurora.flac".into()), + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + }]) + .unwrap(); + + let stats = repo + .upsert_batch_with_remap( + &[TrackRow { + server_id: "s1".into(), + id: "tr_new".into(), + title: "Aurora".into(), + title_sort: None, + artist: Some("A".into()), + artist_id: None, + album: "An Album".into(), + album_id: None, + album_artist: None, + duration_sec: 240, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: Some("/path/aurora.flac".into()), + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 2, + raw_json: "{}".into(), + }], + true, + ) + .unwrap(); + assert_eq!(stats.remapped.len(), 1); + + let ids: Vec = store + .with_conn("misc", |c| { + let mut s = c.prepare("SELECT id FROM track WHERE server_id='s1' ORDER BY id")?; + let r: rusqlite::Result> = s.query_map([], |r| r.get(0))?.collect(); + r + }) + .unwrap(); + assert_eq!(ids, vec!["tr_new"]); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/mapping.rs b/src-tauri/crates/psysonic-library/src/sync/mapping.rs new file mode 100644 index 00000000..6c097e0c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/mapping.rs @@ -0,0 +1,303 @@ +//! Subsonic / Navidrome song JSON → `TrackRow`. PR-3b's ingest paths +//! all feed the same upsert API, so the projection happens here once. + +use serde_json::Value; + +use crate::repos::TrackRow; +use psysonic_integration::subsonic::Song; + +/// Project a Subsonic `Song` plus its raw JSON sub-tree into a +/// `TrackRow`. `raw_value` is what `track.raw_json` stores verbatim so +/// OpenSubsonic extensions survive (spec §5.1 / ADR-7). +pub fn subsonic_song_to_track_row( + server_id: &str, + song: &Song, + raw_value: &Value, + synced_at: i64, + library_id_fallback: Option<&str>, +) -> TrackRow { + TrackRow { + server_id: server_id.to_string(), + id: song.id.clone(), + title: song.title.clone(), + title_sort: None, + artist: song.artist.clone(), + artist_id: song.artist_id.clone(), + album: song.album.clone().unwrap_or_default(), + album_id: song.album_id.clone(), + album_artist: song.album_artist.clone(), + duration_sec: song.duration.unwrap_or(0), + track_number: song.track_number, + disc_number: song.disc_number, + year: song.year, + genre: song.genre.clone(), + suffix: song.suffix.clone(), + bit_rate: song.bit_rate, + size_bytes: song.size, + cover_art_id: song.cover_art.clone(), + starred_at: parse_iso_ms(song.starred.as_deref()), + user_rating: song.user_rating, + play_count: song.play_count, + played_at: parse_iso_ms(song.played.as_deref()), + server_path: song.path.clone(), + library_id: song.library_id.clone().or_else(|| library_id_fallback.map(String::from)), + isrc: song.isrc.clone(), + mbid_recording: song.mbid_recording.clone(), + bpm: song.bpm, + replay_gain_track_db: raw_value + .get("replayGain") + .and_then(|rg| rg.get("trackGain")) + .and_then(|v| v.as_f64()), + replay_gain_album_db: raw_value + .get("replayGain") + .and_then(|rg| rg.get("albumGain")) + .and_then(|v| v.as_f64()), + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at, + raw_json: raw_value.to_string(), + } +} + +/// Project a Navidrome `/api/song` row (native REST shape) into a +/// `TrackRow`. Field names mostly overlap with Subsonic but use +/// snake_case JSON aliases — we read fields by `get(name)` rather +/// than reusing the Subsonic `Song` deserializer so a server-side +/// rename doesn't silently zero out hot columns. +pub fn navidrome_song_to_track_row( + server_id: &str, + raw: &Value, + synced_at: i64, + library_id_fallback: Option<&str>, +) -> Option { + let id = raw.get("id").and_then(|v| v.as_str())?.to_string(); + let title = raw + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let server_updated_at = raw + .get("updatedAt") + .and_then(|v| v.as_str()) + .and_then(parse_iso_ms_str); + let library_id = json_string_field(raw, "libraryId") + .or_else(|| json_string_field(raw, "library_id")) + .or_else(|| json_string_field(raw, "musicFolderId")) + .or_else(|| library_id_fallback.map(String::from)); + Some(TrackRow { + server_id: server_id.to_string(), + id, + title, + title_sort: string_field(raw, "sortTitle").or_else(|| string_field(raw, "orderTitle")), + artist: string_field(raw, "artist"), + artist_id: string_field(raw, "artistId"), + album: string_field(raw, "album").unwrap_or_default(), + album_id: string_field(raw, "albumId"), + album_artist: string_field(raw, "albumArtist"), + duration_sec: raw.get("duration").and_then(|v| v.as_i64()).unwrap_or(0), + track_number: raw.get("trackNumber").and_then(|v| v.as_i64()), + disc_number: raw.get("discNumber").and_then(|v| v.as_i64()), + year: raw.get("year").and_then(|v| v.as_i64()), + genre: string_field(raw, "genre"), + suffix: string_field(raw, "suffix"), + bit_rate: raw.get("bitRate").and_then(|v| v.as_i64()), + size_bytes: raw.get("size").and_then(|v| v.as_i64()), + cover_art_id: string_field(raw, "coverArtId").or_else(|| string_field(raw, "coverArt")), + starred_at: raw.get("starredAt").and_then(|v| v.as_str()).and_then(parse_iso_ms_str), + user_rating: raw.get("rating").and_then(|v| v.as_i64()), + play_count: raw.get("playCount").and_then(|v| v.as_i64()), + played_at: raw.get("playedAt").and_then(|v| v.as_str()).and_then(parse_iso_ms_str), + server_path: string_field(raw, "path"), + library_id, + isrc: string_field(raw, "isrc"), + mbid_recording: string_field(raw, "mbzTrackId").or_else(|| string_field(raw, "musicBrainzId")), + bpm: raw.get("bpm").and_then(|v| v.as_i64()), + replay_gain_track_db: raw.get("rgTrackGain").and_then(|v| v.as_f64()), + replay_gain_album_db: raw.get("rgAlbumGain").and_then(|v| v.as_f64()), + content_hash: None, + server_updated_at, + server_created_at: raw + .get("createdAt") + .and_then(|v| v.as_str()) + .and_then(parse_iso_ms_str), + deleted: false, + synced_at, + raw_json: raw.to_string(), + }) +} + +fn json_string_field(raw: &Value, key: &str) -> Option { + match raw.get(key)? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +fn string_field(raw: &Value, key: &str) -> Option { + json_string_field(raw, key) +} + +fn parse_iso_ms(s: Option<&str>) -> Option { + s.and_then(parse_iso_ms_str) +} + +/// Lightweight ISO-8601 → epoch-ms parser. Supports the Navidrome / +/// OpenSubsonic shape (`2024-06-01T12:00:00Z` or +/// `2024-06-01T12:00:00.123+02:00`). Falls back to `None` on parse +/// failure — sync code never panics on a bad timestamp. +fn parse_iso_ms_str(s: &str) -> Option { + // Strip fractional + timezone before doing the manual parse — + // SQLite stores starred_at / played_at as integer ms, so we only + // need second precision rounded up from the offset. + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + // Accept either `Z`, `+HH:MM`, or no suffix. Reduce to a flat + // `YYYY-MM-DDTHH:MM:SS` core for parsing — server-side timestamps + // are already in UTC for Navidrome, and we don't track timezone + // in the schema column. + let core = trimmed + .find(|c: char| c == '.' || c == 'Z' || c == '+' || (c == '-' && trimmed.find('T').is_some_and(|t| trimmed[t..].contains(c)))) + .map(|i| &trimmed[..i]) + .unwrap_or(trimmed); + let mut parts = core.split(['T', '-', ':']); + let year: i64 = parts.next()?.parse().ok()?; + let month: i64 = parts.next()?.parse().ok()?; + let day: i64 = parts.next()?.parse().ok()?; + let hour: i64 = parts.next().unwrap_or("0").parse().ok()?; + let minute: i64 = parts.next().unwrap_or("0").parse().ok()?; + let second: i64 = parts.next().unwrap_or("0").parse().ok()?; + if !(1970..=2100).contains(&year) + || !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=60).contains(&second) + { + return None; + } + // Days since 1970-01-01 — Howard Hinnant's civil_from_days inverse. + let y = if month <= 2 { year - 1 } else { year }; + let era = y.div_euclid(400); + let yoe = y - era * 400; // [0, 399] + let m = if month > 2 { month - 3 } else { month + 9 }; + let doy = (153 * m + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + let seconds = days * 86_400 + hour * 3600 + minute * 60 + second; + Some(seconds.saturating_mul(1000)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_iso_handles_zulu_suffix() { + // 2024-01-01T00:00:00Z = 1704067200000 ms. + let ms = parse_iso_ms_str("2024-01-01T00:00:00Z").unwrap(); + assert_eq!(ms, 1_704_067_200_000); + } + + #[test] + fn parse_iso_handles_fractional_and_offset() { + let ms = parse_iso_ms_str("2024-01-01T00:00:00.123+02:00").unwrap(); + // Truncated before offset → same epoch-second as Zulu of the + // wall-clock value. Good enough for the schema's integer-ms + // column. + assert_eq!(ms, 1_704_067_200_000); + } + + #[test] + fn parse_iso_rejects_garbage() { + assert!(parse_iso_ms_str("").is_none()); + assert!(parse_iso_ms_str("not-a-date").is_none()); + assert!(parse_iso_ms_str("9999-99-99").is_none()); + } + + #[test] + fn subsonic_song_maps_hot_columns_and_keeps_raw_json() { + let raw = json!({ + "id": "tr_1", + "title": "Hello", + "artist": "World", + "albumId": "al_1", + "duration": 240, + "track": 3, + "year": 2024, + "musicBrainzId": "mb-1", + "replayGain": { "trackGain": -1.2, "albumGain": -0.8 } + }); + let song: Song = serde_json::from_value(raw.clone()).unwrap(); + let row = subsonic_song_to_track_row("s1", &song, &raw, 1_000, Some("lib-fb")); + assert_eq!(row.id, "tr_1"); + assert_eq!(row.album_id.as_deref(), Some("al_1")); + assert_eq!(row.duration_sec, 240); + assert_eq!(row.mbid_recording.as_deref(), Some("mb-1")); + assert_eq!(row.replay_gain_track_db, Some(-1.2)); + assert_eq!(row.replay_gain_album_db, Some(-0.8)); + // Fallback library_id kicks in when the song didn't ship one. + assert_eq!(row.library_id.as_deref(), Some("lib-fb")); + assert!(row.raw_json.contains("replayGain")); + } + + #[test] + fn navidrome_song_maps_native_field_shape() { + let raw = json!({ + "id": "tr_1", + "title": "Hello", + "artist": "World", + "artistId": "ar_1", + "album": "An Album", + "albumId": "al_1", + "albumArtist": "World", + "duration": 240, + "trackNumber": 3, + "discNumber": 1, + "year": 2024, + "genre": "Ambient", + "suffix": "flac", + "bitRate": 1000, + "size": 32_000_000_i64, + "path": "World/An Album/03.flac", + "libraryId": "1", + "isrc": "USRC17607839", + "mbzTrackId": "mb-1", + "bpm": 128, + "rgTrackGain": -1.2, + "rgAlbumGain": -0.8, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + }); + let row = navidrome_song_to_track_row("s1", &raw, 9_999, None).unwrap(); + assert_eq!(row.id, "tr_1"); + assert_eq!(row.track_number, Some(3)); + assert_eq!(row.isrc.as_deref(), Some("USRC17607839")); + assert_eq!(row.mbid_recording.as_deref(), Some("mb-1")); + assert_eq!(row.replay_gain_track_db, Some(-1.2)); + assert_eq!(row.library_id.as_deref(), Some("1")); + assert!(row.server_updated_at.unwrap_or(0) > 0); + } + + #[test] + fn navidrome_song_maps_numeric_library_id() { + let raw = json!({ + "id": "tr_1", + "title": "Hello", + "libraryId": 3 + }); + let row = navidrome_song_to_track_row("s1", &raw, 1, None).unwrap(); + assert_eq!(row.library_id.as_deref(), Some("3")); + } + + #[test] + fn navidrome_song_skips_rows_without_id() { + let row = navidrome_song_to_track_row("s1", &json!({"title": "no id"}), 1, None); + assert!(row.is_none()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/mod.rs b/src-tauri/crates/psysonic-library/src/sync/mod.rs new file mode 100644 index 00000000..a3521378 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/mod.rs @@ -0,0 +1,40 @@ +//! Sync orchestrator. +//! +//! PR-3a landed the foundation (`CapabilityProbe` C1 + +//! `SyncStateRepository` C7); PR-3b adds the initial-sync runner, +//! ingest-strategy selection, backoff, and the §6.9 id remap path. +//! `DeltaSyncRunner` / background scheduler / Tauri surface follow in +//! PR-3c / PR-3d / PR-5. + +pub mod backoff; +pub mod bandwidth; +pub mod budget; +pub mod capability; +pub mod cursor; +pub mod delta; +pub mod error; +pub mod ingest_parallel; +pub mod initial; +pub mod mapping; +pub mod poll_stats; +pub mod progress; +pub mod scheduler; +pub mod strategy; +pub mod supervisor; +pub mod tombstone; + +pub use backoff::{with_jitter, Backoff}; +pub use bandwidth::{ParallelismBudget, PlaybackHint}; +pub use budget::{PassKind, RequestBudget}; +pub use capability::{CapabilityFlags, CapabilityProbe, NavidromeProbeCredentials}; +pub use cursor::{CursorPhase, InitialSyncCursor, StrategyState}; +pub use delta::{DeltaSyncReport, DeltaSyncRunner}; +pub use error::SyncError; +pub use initial::{InitialSyncReport, InitialSyncRunner}; +pub use mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; +pub use poll_stats::{classify_tier, next_interval_ms, LibraryTier, PollStats}; +pub use progress::{ChannelProgress, NoopProgress, Progress, ProgressEvent}; +pub use scheduler::{BackgroundScheduler, SchedulerTickReport, DEFAULT_TOMBSTONE_THRESHOLD_PCT}; +pub use strategy::IngestStrategy; +pub use supervisor::SyncSupervisor; +pub use tombstone::{should_auto_reconcile, TombstoneReconciler, TombstoneReport}; diff --git a/src-tauri/crates/psysonic-library/src/sync/poll_stats.rs b/src-tauri/crates/psysonic-library/src/sync/poll_stats.rs new file mode 100644 index 00000000..dfc3b0e1 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/poll_stats.rs @@ -0,0 +1,250 @@ +//! C10 — adaptive poll interval (spec §6.2.2). +//! +//! Rolling EWMA over the last delta pass's HTTP response stats plus +//! the artist-count signal classifies the server into a `LibraryTier`, +//! which then drives the next poll interval. Persisted in +//! `sync_state.poll_stats_json` so the scheduler picks up where it +//! left off across restarts. + +use serde::{Deserialize, Serialize}; + +/// EWMA smoothing factor — higher values weight recent samples more. +/// 0.3 matches the §6.2.2 "rolling EWMA" target without over-reacting +/// to a single slow response. +pub const EWMA_ALPHA: f64 = 0.3; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LibraryTier { + /// `<2k` artists. + Small, + /// `2k-15k` artists. + Medium, + /// `>15k` artists OR `ewma_bytes > 2 MB`. + Huge, + #[default] + Unknown, +} + +impl LibraryTier { + pub fn as_tag(self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Huge => "huge", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct PollStats { + /// Last successful `getArtists.index` cardinality, or + /// `getScanStatus.count` estimate. Used as the primary tier + /// signal. + #[serde(default)] + pub artist_count: u64, + /// EWMA of the most recent poll response sizes, in bytes. + /// Decompressed (post-gzip) per §2.2.1. + #[serde(default)] + pub ewma_bytes: f64, + /// EWMA of the most recent poll wall-clock durations, in ms. + #[serde(default)] + pub ewma_duration_ms: f64, + /// Resolved tier from the inputs above. + #[serde(default)] + pub library_tier: LibraryTier, +} + +impl PollStats { + /// Fold a new sample into the EWMAs. First sample seeds the + /// averages directly so a single fresh poll yields a meaningful + /// tier classification. + pub fn observe(&mut self, bytes: u64, duration_ms: u64) { + let b = bytes as f64; + let d = duration_ms as f64; + self.ewma_bytes = if self.ewma_bytes == 0.0 { + b + } else { + EWMA_ALPHA * b + (1.0 - EWMA_ALPHA) * self.ewma_bytes + }; + self.ewma_duration_ms = if self.ewma_duration_ms == 0.0 { + d + } else { + EWMA_ALPHA * d + (1.0 - EWMA_ALPHA) * self.ewma_duration_ms + }; + } + + /// Update `artist_count` and recompute the resolved tier. + pub fn set_artist_count(&mut self, count: u64) { + self.artist_count = count; + self.library_tier = classify_tier(self.artist_count, self.ewma_bytes); + } + + /// Force a tier re-classification — call after a fresh + /// `observe()` pair if a borderline `ewma_bytes` may have tipped + /// the threshold. + pub fn reclassify(&mut self) { + self.library_tier = classify_tier(self.artist_count, self.ewma_bytes); + } +} + +/// §6.2.2 classification table. +pub fn classify_tier(artist_count: u64, ewma_bytes: f64) -> LibraryTier { + if artist_count == 0 && ewma_bytes == 0.0 { + return LibraryTier::Unknown; + } + if artist_count > 15_000 || ewma_bytes > 2_000_000.0 { + return LibraryTier::Huge; + } + if artist_count >= 2_000 { + return LibraryTier::Medium; + } + LibraryTier::Small +} + +/// Spec §6.2.2 formula. Returns the delta in milliseconds — caller +/// stamps `next_poll_at = now + this`. All arithmetic in `f64` so the +/// `load_factor` clamp produces a smooth curve. +pub fn next_interval_ms(stats: &PollStats) -> u64 { + let base_ms: u64 = match stats.library_tier { + LibraryTier::Huge => 15 * 60 * 1000, + LibraryTier::Medium => 10 * 60 * 1000, + LibraryTier::Small => 5 * 60 * 1000, + // No data yet — start short so the first real tick lands + // quickly and re-classifies. + LibraryTier::Unknown => 60 * 1000, + }; + let load_factor = if stats.ewma_duration_ms <= 0.0 { + 1.0 + } else { + (stats.ewma_duration_ms / 3000.0).clamp(1.0, 10.0) + }; + let artist_factor: f64 = if matches!(stats.library_tier, LibraryTier::Huge) { + 3.0 + } else { + 1.0 + }; + let scaled = (base_ms as f64) * load_factor * artist_factor; + scaled.min(u64::MAX as f64) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_returns_unknown_with_zero_signals() { + assert_eq!(classify_tier(0, 0.0), LibraryTier::Unknown); + } + + #[test] + fn classify_small_under_2k_artists() { + assert_eq!(classify_tier(500, 1000.0), LibraryTier::Small); + assert_eq!(classify_tier(1_999, 1000.0), LibraryTier::Small); + } + + #[test] + fn classify_medium_in_range() { + assert_eq!(classify_tier(2_000, 1000.0), LibraryTier::Medium); + assert_eq!(classify_tier(15_000, 1000.0), LibraryTier::Medium); + } + + #[test] + fn classify_huge_above_15k_artists() { + assert_eq!(classify_tier(15_001, 1000.0), LibraryTier::Huge); + } + + #[test] + fn classify_huge_when_ewma_bytes_exceed_2mb_even_with_few_artists() { + // Slow-network signal — the spec's `ewma_bytes > 2MB` override. + assert_eq!(classify_tier(500, 2_500_000.0), LibraryTier::Huge); + } + + #[test] + fn observe_first_sample_seeds_ewmas_directly() { + let mut s = PollStats::default(); + s.observe(100_000, 1500); + assert_eq!(s.ewma_bytes, 100_000.0); + assert_eq!(s.ewma_duration_ms, 1500.0); + } + + #[test] + fn observe_subsequent_samples_apply_alpha_smoothing() { + let mut s = PollStats::default(); + s.observe(100_000, 1000); + s.observe(200_000, 2000); + // 0.3 * 200_000 + 0.7 * 100_000 = 60_000 + 70_000 = 130_000 + assert!((s.ewma_bytes - 130_000.0).abs() < 0.001); + assert!((s.ewma_duration_ms - 1300.0).abs() < 0.001); + } + + #[test] + fn set_artist_count_triggers_reclassification() { + let mut s = PollStats::default(); + s.observe(50_000, 1500); + s.set_artist_count(5_000); + assert_eq!(s.library_tier, LibraryTier::Medium); + } + + #[test] + fn next_interval_unknown_tier_starts_short() { + let s = PollStats::default(); + assert_eq!(next_interval_ms(&s), 60_000); + } + + #[test] + fn next_interval_small_base_5min_at_idle_load() { + let mut s = PollStats::default(); + s.set_artist_count(1000); + // load_factor clamps at 1.0 when ewma_duration_ms = 0. + assert_eq!(next_interval_ms(&s), 5 * 60_000); + } + + #[test] + fn next_interval_huge_uses_artist_factor_3x() { + let mut s = PollStats::default(); + s.set_artist_count(20_000); + // 15 min * 3 = 45 min on idle load. + assert_eq!(next_interval_ms(&s), 45 * 60_000); + } + + #[test] + fn next_interval_load_factor_stretches_with_slow_network() { + let mut s = PollStats::default(); + s.set_artist_count(1000); + s.observe(50_000, 9_000); // 3× the 3000ms target + // 5 min * 3 (load_factor) = 15 min. + let ms = next_interval_ms(&s); + assert!( + (14 * 60_000..=16 * 60_000).contains(&ms), + "expected ~15min, got {ms}ms" + ); + } + + #[test] + fn next_interval_load_factor_clamped_at_10x() { + let mut s = PollStats::default(); + s.set_artist_count(1000); + s.observe(50_000, 60_000); // 20× target → clamps at 10 + assert_eq!(next_interval_ms(&s), 10 * 5 * 60_000); + } + + #[test] + fn poll_stats_round_trips_through_json() { + let mut s = PollStats::default(); + s.observe(123_456, 789); + s.set_artist_count(3_500); + let json = serde_json::to_value(s).unwrap(); + let back: PollStats = serde_json::from_value(json).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn poll_stats_deserialize_tolerates_missing_fields() { + // Stored default is `'{}'` per spec §5.1; runner must accept it + // as a fresh stats object. + let s: PollStats = serde_json::from_str("{}").unwrap(); + assert_eq!(s, PollStats::default()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/progress.rs b/src-tauri/crates/psysonic-library/src/sync/progress.rs new file mode 100644 index 00000000..b6cd4e59 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/progress.rs @@ -0,0 +1,329 @@ +//! C6 — progress channel for the sync runners (spec §6 emit limit +//! `≤2 events/s`). The runners call `Progress::emit` at phase +//! transitions and per-batch checkpoints; the supervisor wraps an +//! `mpsc::UnboundedSender` so the top crate (PR-5) can forward events +//! to Tauri's emit surface. +//! +//! Non-terminal events are rate-limited. `IngestPage` updates are +//! **coalesced** (latest `ingested_total` wins) so fast S1/N1 batches +//! do not leave the UI stuck on a stale count between throttled emits. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use crate::repos::RemapEntry; + +/// Per-batch ingest timings (DevTools + terminal diagnosis). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IngestBatchMetrics { + pub offset: u32, + pub strategy: String, + pub fetch_ms: u32, + pub write_ms: u32, + pub lock_wait_ms: u32, + pub sql_exec_ms: u32, + pub persist_ms: u32, + pub row_count: u32, + pub bulk_ingest_active: bool, +} + +/// Lean event union — server_id / library_scope context lives on the +/// channel side (one supervisor = one scope). Top-crate code wraps +/// these into Tauri events with their own envelope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProgressEvent { + PhaseChanged { phase: String }, + IngestPage { + ingested_total: u32, + batch_count: u32, + metrics: Option, + }, + Remapped { entries: Vec }, + Tombstoned { deleted_count: u32, checked_count: u32 }, + Completed { kind: String }, + Error { message: String }, +} + +impl ProgressEvent { + /// Terminal events always bypass the throttle so callers never + /// miss a "we're done" / "we crashed" signal. + pub fn always_emit(&self) -> bool { + matches!(self, Self::Completed { .. } | Self::Error { .. }) + } +} + +pub trait Progress: Send + Sync { + fn emit(&self, event: ProgressEvent); +} + +/// No-op implementation. Used as the default when runners are called +/// outside a supervisor (tests, future ad-hoc invocations). +pub struct NoopProgress; + +impl Progress for NoopProgress { + fn emit(&self, _event: ProgressEvent) {} +} + +/// `Progress` impl that forwards through a tokio mpsc channel, +/// throttling non-terminal events to the configured `min_interval`. +pub struct ChannelProgress { + sender: tokio::sync::mpsc::UnboundedSender, + min_interval: Duration, + last_emit: Mutex>, + /// Latest ingest checkpoint held back while the throttle gate is closed. + pending_ingest: Mutex)>>, +} + +impl ChannelProgress { + /// 500 ms gate ≈ 2 events/s per spec §6. + pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(500); + + pub fn new(sender: tokio::sync::mpsc::UnboundedSender) -> Self { + Self::with_interval(sender, Self::DEFAULT_INTERVAL) + } + + pub fn with_interval( + sender: tokio::sync::mpsc::UnboundedSender, + min_interval: Duration, + ) -> Self { + Self { + sender, + min_interval, + last_emit: Mutex::new(None), + pending_ingest: Mutex::new(None), + } + } + + fn flush_pending_ingest(&self) { + let pending = self + .pending_ingest + .lock() + .expect("progress pending lock poisoned") + .take(); + if let Some((ingested_total, batch_count, metrics)) = pending { + let _ = self.sender.send(ProgressEvent::IngestPage { + ingested_total, + batch_count, + metrics, + }); + } + } + + fn throttle_open(&self) -> bool { + if self.min_interval.is_zero() { + return true; + } + let last = self.last_emit.lock().expect("progress lock poisoned"); + match *last { + None => true, + Some(prev) if prev.elapsed() >= self.min_interval => true, + Some(_) => false, + } + } + + fn mark_emitted(&self) { + if self.min_interval.is_zero() { + return; + } + *self + .last_emit + .lock() + .expect("progress lock poisoned") = Some(Instant::now()); + } +} + +impl Progress for ChannelProgress { + fn emit(&self, event: ProgressEvent) { + if event.always_emit() { + self.flush_pending_ingest(); + let _ = self.sender.send(event); + return; + } + + if let ProgressEvent::IngestPage { + ingested_total, + batch_count, + metrics, + } = event + { + let gate_open = self.throttle_open(); + { + let mut pending = self + .pending_ingest + .lock() + .expect("progress pending lock poisoned"); + if gate_open { + if let Some((total, batch, m)) = pending.take() { + let _ = self.sender.send(ProgressEvent::IngestPage { + ingested_total: total, + batch_count: batch, + metrics: m, + }); + } + } + *pending = Some((ingested_total, batch_count, metrics)); + } + if gate_open { + self.mark_emitted(); + self.flush_pending_ingest(); + } + return; + } + + if !self.min_interval.is_zero() && !self.throttle_open() { + return; + } + self.flush_pending_ingest(); + self.mark_emitted(); + let _ = self.sender.send(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + use tokio::sync::mpsc; + + #[test] + fn noop_progress_swallows_events_without_panicking() { + let p = NoopProgress; + p.emit(ProgressEvent::PhaseChanged { phase: "ingest".into() }); + p.emit(ProgressEvent::Completed { kind: "initial_sync".into() }); + } + + #[tokio::test(flavor = "multi_thread")] + async fn zero_interval_channel_emits_every_event() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::ZERO); + for i in 0..10 { + p.emit(ProgressEvent::IngestPage { + ingested_total: i, + batch_count: 1, + metrics: None, + }); + } + let mut received = 0; + while rx.try_recv().is_ok() { + received += 1; + } + assert_eq!(received, 10, "ZERO interval must not drop anything"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn terminal_events_bypass_throttle() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::from_secs(60)); + p.emit(ProgressEvent::IngestPage { + ingested_total: 999, + batch_count: 3, + metrics: None, + }); + p.emit(ProgressEvent::Completed { kind: "delta_sync".into() }); + p.emit(ProgressEvent::Error { message: "boom".into() }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 999, + .. + }) + )); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::Completed { .. }) + )); + assert!(matches!(rx.try_recv(), Ok(ProgressEvent::Error { .. }))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn non_terminal_events_collapse_under_throttle() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::from_millis(100)); + p.emit(ProgressEvent::PhaseChanged { phase: "a".into() }); + p.emit(ProgressEvent::PhaseChanged { phase: "b".into() }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::PhaseChanged { ref phase }) if phase == "a" + )); + assert!(rx.try_recv().is_err(), "second emit must have been dropped"); + + thread::sleep(Duration::from_millis(120)); + p.emit(ProgressEvent::PhaseChanged { phase: "c".into() }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::PhaseChanged { ref phase }) if phase == "c" + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn ingest_pages_coalesce_to_latest_within_throttle_window() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::from_millis(100)); + p.emit(ProgressEvent::IngestPage { + ingested_total: 500, + batch_count: 1, + metrics: None, + }); + p.emit(ProgressEvent::IngestPage { + ingested_total: 2500, + batch_count: 5, + metrics: None, + }); + p.emit(ProgressEvent::IngestPage { + ingested_total: 5000, + batch_count: 10, + metrics: None, + }); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 500, + .. + }) + )); + assert!(rx.try_recv().is_err(), "bursts must coalesce, not stack"); + + thread::sleep(Duration::from_millis(120)); + p.emit(ProgressEvent::IngestPage { + ingested_total: 5500, + batch_count: 11, + metrics: None, + }); + assert!( + matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 5000, + .. + }) + ), + "latest pending count must flush when the gate opens" + ); + assert!(matches!( + rx.try_recv(), + Ok(ProgressEvent::IngestPage { + ingested_total: 5500, + .. + }) + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn closed_receiver_does_not_panic_the_sender() { + let (tx, rx) = mpsc::unbounded_channel(); + let p = ChannelProgress::with_interval(tx, Duration::ZERO); + drop(rx); + p.emit(ProgressEvent::PhaseChanged { phase: "x".into() }); + } + + #[test] + fn always_emit_true_for_terminal_events() { + assert!(ProgressEvent::Completed { kind: "k".into() }.always_emit()); + assert!(ProgressEvent::Error { message: "m".into() }.always_emit()); + assert!(!ProgressEvent::PhaseChanged { phase: "p".into() }.always_emit()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/scheduler.rs b/src-tauri/crates/psysonic-library/src/sync/scheduler.rs new file mode 100644 index 00000000..d7d64372 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/scheduler.rs @@ -0,0 +1,619 @@ +//! C8 — background scheduler (spec §6.2). +//! +//! Tick-based: the top crate (PR-5) drives the actual timer; PR-3d2 +//! ships the logic that decides "is it time?", picks the budget + +//! tombstone trigger, runs the DeltaSyncRunner, and writes back the +//! adaptive interval. +//! +//! Owns no tokio task itself — keeps testability high and lets the +//! caller decide spawn behaviour (Supervisor or inline). + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use psysonic_integration::subsonic::SubsonicClient; + +use super::bandwidth::{ParallelismBudget, PlaybackHint}; +use super::budget::{PassKind, RequestBudget}; +use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; +use super::delta::{DeltaSyncReport, DeltaSyncRunner}; +use super::error::SyncError; +use super::poll_stats::{next_interval_ms, PollStats}; +use super::progress::{NoopProgress, Progress}; +use super::tombstone::should_auto_reconcile; +use crate::repos::SyncStateRepository; +use crate::store::LibraryStore; + +/// Default Mode B threshold per §6.7 (5 % gap before auto reconcile). +pub const DEFAULT_TOMBSTONE_THRESHOLD_PCT: u32 = 5; + +/// Outcome of one scheduler tick — what happened plus the resolved +/// `next_poll_at` so the caller can re-schedule its timer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SchedulerTickReport { + pub skipped_not_due: bool, + pub skipped_bulk_paused: bool, + /// Delta/tombstone pass deferred while initial sync or capability probe + /// holds `sync_phase`, IS-3 bulk ingest is active, or a foreground sync + /// job (`LibraryRuntime::current_job`) is running for this server. + pub skipped_sync_pass_active: bool, + pub delta: Option, + pub next_poll_at_ms: i64, +} + +pub struct BackgroundScheduler<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + navidrome: Option, + server_id: String, + library_scope: String, + capability_flags: CapabilityFlags, + playback_hint: PlaybackHint, + cancel: Option>, + progress: Arc, + tombstone_threshold_pct: u32, + sleep_enabled: bool, + /// When true, a user-triggered sync job (delta / verify / full resync) + /// already owns this server — skip the background delta pass. + foreground_sync_job_active: bool, +} + +impl<'a> BackgroundScheduler<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + library_scope: impl Into, + capability_flags: CapabilityFlags, + ) -> Self { + Self { + store, + subsonic, + navidrome: None, + server_id: server_id.into(), + library_scope: library_scope.into(), + capability_flags, + playback_hint: PlaybackHint::Idle, + cancel: None, + progress: Arc::new(NoopProgress), + tombstone_threshold_pct: DEFAULT_TOMBSTONE_THRESHOLD_PCT, + sleep_enabled: true, + foreground_sync_job_active: false, + } + } + + pub fn with_navidrome_credentials(mut self, creds: NavidromeProbeCredentials) -> Self { + self.navidrome = Some(creds); + self + } + + pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self { + self.playback_hint = hint; + self + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = progress; + self + } + + pub fn with_tombstone_threshold_pct(mut self, pct: u32) -> Self { + self.tombstone_threshold_pct = pct; + self + } + + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + pub fn with_foreground_sync_job_active(mut self, active: bool) -> Self { + self.foreground_sync_job_active = active; + self + } + + /// `true` when `next_poll_at` has passed (or no value yet). Caller + /// short-circuits its timer when this returns `false`. + pub fn is_due(&self, now_ms: i64) -> Result { + let sync_state = SyncStateRepository::new(self.store); + let next = sync_state + .get_next_poll_at(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + Ok(next.map(|n| now_ms >= n).unwrap_or(true)) + } + + /// Resolve the parallelism budget for the current playback state. + /// Bulk-paused state means the scheduler skips the tick entirely + /// and just re-schedules. + pub fn parallelism_budget(&self) -> ParallelismBudget { + ParallelismBudget::resolve(self.playback_hint) + } + + /// Run one tick — runs a delta sync if due and bulk isn't paused + /// by the playback signal, then writes the new `next_poll_at`. + pub async fn tick(&self, now_ms: i64) -> Result { + let sync_state = SyncStateRepository::new(self.store); + sync_state + .ensure(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + + let mut report = SchedulerTickReport { + skipped_not_due: false, + skipped_bulk_paused: false, + skipped_sync_pass_active: false, + delta: None, + next_poll_at_ms: now_ms, + }; + + if self.sync_pass_active(&sync_state)? { + report.skipped_sync_pass_active = true; + report.next_poll_at_ms = now_ms + 30_000; + sync_state + .set_next_poll_at(&self.server_id, &self.library_scope, report.next_poll_at_ms) + .map_err(SyncError::Storage)?; + crate::app_eprintln!( + "[library-sync] scheduler tick skipped: sync pass active (phase={:?}, bulk={})", + sync_state + .get_sync_phase(&self.server_id, &self.library_scope) + .ok() + .flatten(), + self.store.bulk_ingest_active() + ); + return Ok(report); + } + + if !self.is_due(now_ms)? { + report.skipped_not_due = true; + let stats = self.load_poll_stats(&sync_state)?; + report.next_poll_at_ms = now_ms + next_interval_ms(&stats) as i64; + return Ok(report); + } + + let parallelism = self.parallelism_budget(); + if parallelism.bulk_paused() { + // §6.2.4 PrefetchActive — skip this tick entirely, re-poll + // soon so we can catch the prefetch finishing. + report.skipped_bulk_paused = true; + report.next_poll_at_ms = now_ms + 30_000; // ~30s short retry + sync_state + .set_next_poll_at(&self.server_id, &self.library_scope, report.next_poll_at_ms) + .map_err(SyncError::Storage)?; + return Ok(report); + } + + // Decide budget + tombstone trigger. + let mut tombstone_budget: u32 = 0; + if let (Some(local), Some(server)) = ( + sync_state + .get_local_track_count(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?, + sync_state + .get_server_track_count(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?, + ) { + let (local_u, server_u) = (local.max(0) as u32, server.max(0) as u32); + if should_auto_reconcile(local_u, server_u, self.tombstone_threshold_pct) { + tombstone_budget = RequestBudget::DELTA_MISMATCH_CAP; + } + } + let _pass_budget = if tombstone_budget > 0 { + RequestBudget::for_pass(PassKind::DeltaMismatch) + } else { + RequestBudget::for_pass(PassKind::DeltaLight) + }; + // PR-3d2 doesn't enforce pass_budget against the runner yet — + // delta runner is already small (1 probe + ≤8 album-list + // pages); the budget value is recorded so PR-5 can surface it + // in Settings. Wire actual cap in the runner when DS-7 + // starred delta or other request-heavy paths land. + + // Run the delta pass. + let mut runner = DeltaSyncRunner::new( + self.store, + self.subsonic, + &self.server_id, + &self.library_scope, + self.capability_flags, + ) + .with_progress(Arc::clone(&self.progress)); + if let Some(creds) = &self.navidrome { + runner = runner.with_navidrome_credentials(creds.clone()); + } + if let Some(flag) = &self.cancel { + runner = runner.with_cancellation(Arc::clone(flag)); + } + if !self.sleep_enabled { + runner = runner.with_sleep_disabled(); + } + if tombstone_budget > 0 { + runner = runner.with_tombstone_budget(tombstone_budget); + } + let delta_report = runner.run().await?; + + // Update poll_stats: nothing measured per-request yet in + // PR-3d2 (PR-5 will plumb byte/duration via a custom HTTP + // wrapper). For now the tier signal updates from artist_count + // when the next probe lands; we just persist the artist_count + // we know from the local DB so the tier classifier has data. + let mut stats = self.load_poll_stats(&sync_state)?; + if delta_report.changed_count > 0 { + // Re-stamp the local count snapshot so the next tick's + // threshold check has fresh data. + if let Ok(local) = self.count_local_tracks() { + sync_state + .set_local_track_count(&self.server_id, &self.library_scope, local) + .map_err(SyncError::Storage)?; + } + } + stats.reclassify(); + sync_state + .set_library_tier( + &self.server_id, + &self.library_scope, + stats.library_tier.as_tag(), + ) + .map_err(SyncError::Storage)?; + sync_state + .set_poll_stats_json( + &self.server_id, + &self.library_scope, + &serde_json::to_value(stats).unwrap_or_default(), + ) + .map_err(SyncError::Storage)?; + + report.next_poll_at_ms = now_ms + next_interval_ms(&stats) as i64; + sync_state + .set_next_poll_at(&self.server_id, &self.library_scope, report.next_poll_at_ms) + .map_err(SyncError::Storage)?; + + report.delta = Some(delta_report); + Ok(report) + } + + fn load_poll_stats( + &self, + sync_state: &SyncStateRepository<'_>, + ) -> Result { + let raw = sync_state + .get_poll_stats_json(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + match raw { + None => Ok(PollStats::default()), + Some(v) => serde_json::from_value(v).map_err(|e| SyncError::Storage(e.to_string())), + } + } + + /// True while initial sync, capability probe, IS-3 bulk ingest, or a + /// foreground sync job for this server is in flight — background delta + /// must not compete for HTTP budget or tombstone probes. + fn sync_pass_active(&self, sync_state: &SyncStateRepository<'_>) -> Result { + if self.foreground_sync_job_active { + return Ok(true); + } + if self.store.bulk_ingest_active() { + return Ok(true); + } + let phase = sync_state + .get_sync_phase(&self.server_id, &self.library_scope) + .map_err(SyncError::Storage)?; + Ok(matches!( + phase.as_deref(), + Some("initial_sync") | Some("probing") + )) + } + + fn count_local_tracks(&self) -> Result { + self.store + .with_conn("scheduler.count_local_tracks", |c| { + c.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0", + rusqlite::params![self.server_id], + |row| row.get(0), + ) + }) + .map_err(SyncError::Storage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{method as wm_method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_subsonic(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + fn flags(bits: u32) -> CapabilityFlags { + CapabilityFlags::new(bits) + } + + async fn empty_probe_and_albumlist(server: &MockServer, last_modified: i64) { + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getArtists.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "artists": { + "lastModified": last_modified, + "ignoredArticles": "", + "index": [] + } + } + }))) + .mount(server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getAlbumList2.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "albumList2": { "album": [] } + } + }))) + .mount(server) + .await; + } + + // ── is_due ──────────────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn is_due_returns_true_when_no_schedule_yet() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let sched = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + assert!(sched.is_due(0).unwrap()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn is_due_false_when_next_poll_in_future() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_next_poll_at("s1", "", 5_000_000).unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let sched = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ); + assert!(!sched.is_due(1_000_000).unwrap()); + assert!(sched.is_due(5_000_001).unwrap()); + } + + // ── tick skips when not due ────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_skips_while_initial_sync_phase_active() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state + .set_sync_phase("s1", "", "initial_sync") + .unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + assert!(report.skipped_sync_pass_active); + assert!(report.delta.is_none()); + assert_eq!(report.next_poll_at_ms, 30_000); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tick_skips_when_foreground_sync_job_active() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .with_foreground_sync_job_active(true) + .tick(0) + .await + .unwrap(); + + assert!(report.skipped_sync_pass_active); + assert!(report.delta.is_none()); + assert_eq!(report.next_poll_at_ms, 30_000); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tick_skips_when_not_due_and_reports_next_poll() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + sync_state.set_next_poll_at("s1", "", 1_000_000_000).unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(500) + .await + .unwrap(); + + assert!(report.skipped_not_due); + assert!(report.delta.is_none()); + assert!(report.next_poll_at_ms > 500); + } + + // ── tick pauses when PrefetchActive ────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_pauses_when_playback_hint_is_prefetch_active() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_playback_hint(PlaybackHint::PrefetchActive) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + assert!(report.skipped_bulk_paused); + assert!(report.delta.is_none()); + // Re-scheduled soon (≤ 60s after now) so we catch the + // prefetch finishing. + assert!(report.next_poll_at_ms > 0); + assert!(report.next_poll_at_ms <= 60_000); + } + + // ── tick runs delta and stamps next_poll_at ────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_runs_delta_and_persists_next_poll_at() { + let server = MockServer::start().await; + empty_probe_and_albumlist(&server, 1_716_840_000_000).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(1_000) + .await + .unwrap(); + + assert!(!report.skipped_not_due); + assert!(!report.skipped_bulk_paused); + assert!(report.delta.is_some()); + let next = SyncStateRepository::new(&store) + .get_next_poll_at("s1", "") + .unwrap() + .unwrap(); + assert_eq!(next, report.next_poll_at_ms); + assert!(next > 1_000); + } + + // ── auto-tombstone trigger ────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn tick_auto_tombstones_when_count_gap_exceeds_threshold() { + let server = MockServer::start().await; + empty_probe_and_albumlist(&server, 1_716_840_000_000).await; + // Tombstone probe — empty store has nothing to probe, so we + // only need to know the runner *would* have called getSong if + // there were rows. For this test it's enough that no panic + // occurs and the delta report's tombstone counters are zero. + + let store = LibraryStore::open_in_memory(); + let sync_state = SyncStateRepository::new(&store); + sync_state.ensure("s1", "").unwrap(); + // 110 local vs 100 server → 10 % gap, threshold 5 % default. + sync_state.set_local_track_count("s1", "", 110).unwrap(); + sync_state.set_server_track_count("s1", "", 100).unwrap(); + + let subsonic = test_subsonic(&server.uri()); + let report = BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + let delta = report.delta.expect("delta ran"); + // Tombstone budget was set (200), but no local tracks exist → + // nothing to probe, both counters stay at 0. The important + // signal is that the runner accepted the trigger. + assert_eq!(delta.tombstones_checked, 0); + assert_eq!(delta.tombstones_deleted, 0); + } + + // ── PollStats persistence round trip ──────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn poll_stats_persist_round_trip_through_tick() { + let server = MockServer::start().await; + empty_probe_and_albumlist(&server, 1_716_840_000_000).await; + + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + BackgroundScheduler::new( + &store, + &subsonic, + "s1", + "", + flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK), + ) + .with_sleep_disabled() + .tick(0) + .await + .unwrap(); + + let stored = SyncStateRepository::new(&store) + .get_poll_stats_json("s1", "") + .unwrap() + .unwrap(); + // tier is recorded — runner reclassifies even with no + // observations yet, so this is "unknown" on a fresh store. + let stats: PollStats = serde_json::from_value(stored).unwrap(); + assert_eq!(stats.library_tier.as_tag(), "unknown"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/strategy.rs b/src-tauri/crates/psysonic-library/src/sync/strategy.rs new file mode 100644 index 00000000..535e6fba --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/strategy.rs @@ -0,0 +1,216 @@ +//! C2 ingest strategy selection. Per the PR-3 kickoff answer (workdocs +//! `2026-05-19-pr3-kickoff.md` Q3) the choice is made once at initial +//! sync start from the probed capability flags; the runner does not +//! auto-switch on transient failure (C12 retries the same batch). + +use super::capability::CapabilityFlags; + +/// Server track count above which N1 is skipped in favour of S1 at initial +/// sync start (R7-15 Q4). N1's native `/api/song` deep-offset 500 wall makes +/// it unable to finish very large catalogs; S1 (`search3`) does not hit it. +/// Tunable constant — the live wall was observed past ~50k. +pub const LARGE_LIBRARY_THRESHOLD: i64 = 40_000; + +/// Spec §6.3 IS-3 strategies. Names match §6.1.1 capability bits where +/// applicable; S2 has no flag of its own — it's the universal +/// album-crawl fallback assumed available whenever the Subsonic ping +/// succeeds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IngestStrategy { + /// N1 — Navidrome native `GET /api/song` paginated. Cheapest at + /// 500k; requires `NavidromeNativeBulk` flag set by the probe. + N1, + /// S1 — Subsonic `search3` empty query, songOffset paged. Requires + /// `SubsonicSearch3Bulk`. + S1, + /// S2 — `getAlbumList2` + `getAlbum` per album. Universal Subsonic + /// fallback — assumed available whenever the ping returns ok. + S2, + /// S3 — `getIndexes` + `getMusicDirectory` recursive file-tree + /// crawl. Last resort; PR-3b does not auto-select it. + S3, +} + +impl IngestStrategy { + /// Pick the cheapest strategy supported by `flags`. Per kickoff Q3: + /// `N1 → S1 → S2`. S3 is enumerated for completeness but never + /// auto-selected — when neither N1 nor S1 is available, S2 is + /// always tried first because every Subsonic-compliant server + /// exposes `getAlbumList2` + `getAlbum`. + pub fn select_from_flags(flags: CapabilityFlags) -> Self { + if flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK) { + Self::N1 + } else if flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK) { + Self::S1 + } else { + Self::S2 + } + } + + /// Pick the initial-sync strategy with the large-library policy on top + /// of `select_from_flags` (R7-15 Q4). A large catalog — or one already + /// flagged `n1_bulk_unreliable` after an N1 deep-offset 500 — must avoid + /// N1, which cannot finish past the wall. Prefer S1 (`search3`) and fall + /// back to S2 (universal album crawl) when search3 bulk is unavailable. + /// + /// `server_track_count` is best-effort at IS-1 (probe `getScanStatus` + /// count or a prior watermark); `None` means unknown, in which case only + /// the `n1_bulk_unreliable` flag forces the non-N1 path (a first run with + /// no count still tries the cheapest strategy and learns from a 500). + pub fn select_initial_strategy( + flags: CapabilityFlags, + server_track_count: Option, + n1_bulk_unreliable: bool, + ) -> Self { + let is_large = server_track_count + .map(|c| c > LARGE_LIBRARY_THRESHOLD) + .unwrap_or(false); + if n1_bulk_unreliable || is_large { + if flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK) { + return Self::S1; + } + return Self::S2; + } + Self::select_from_flags(flags) + } + + /// String tag stored in `initial_sync_cursor_json` so the runner + /// can resume after restart without re-running capability probe. + pub fn as_tag(self) -> &'static str { + match self { + Self::N1 => "n1", + Self::S1 => "s1", + Self::S2 => "s2", + Self::S3 => "s3", + } + } + + pub fn from_tag(tag: &str) -> Option { + match tag { + "n1" => Some(Self::N1), + "s1" => Some(Self::S1), + "s2" => Some(Self::S2), + "s3" => Some(Self::S3), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_prefers_navidrome_native_when_both_n1_and_s1_present() { + // At 500k, N1 cuts request count by 10× vs S1 — selector must + // pick it whenever the flag is set, regardless of S1. + let flags = CapabilityFlags::new( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ); + assert_eq!(IngestStrategy::select_from_flags(flags), IngestStrategy::N1); + } + + #[test] + fn select_falls_back_to_s1_without_n1() { + let flags = CapabilityFlags::new(CapabilityFlags::SUBSONIC_SEARCH3_BULK); + assert_eq!(IngestStrategy::select_from_flags(flags), IngestStrategy::S1); + } + + #[test] + fn select_falls_back_to_s2_when_no_bulk_flag_set() { + // Generic Subsonic server without search3 bulk → universal + // album crawl. S3 is not auto-selected even with FileTreeBrowse. + let flags = CapabilityFlags::new(CapabilityFlags::FILE_TREE_BROWSE); + assert_eq!(IngestStrategy::select_from_flags(flags), IngestStrategy::S2); + } + + #[test] + fn select_falls_back_to_s2_with_no_flags() { + // Default-flag (`0x000`) — fresh DB before probe runs, or a + // truly minimal Subsonic implementation. Still resolves to a + // strategy; runner surfaces errors if S2 endpoints then fail. + assert_eq!( + IngestStrategy::select_from_flags(CapabilityFlags::default()), + IngestStrategy::S2 + ); + } + + // ── select_initial_strategy (R7-15 Q4 large-library policy) ────────── + + fn navidrome_full() -> CapabilityFlags { + CapabilityFlags::new( + CapabilityFlags::NAVIDROME_NATIVE_BULK | CapabilityFlags::SUBSONIC_SEARCH3_BULK, + ) + } + + #[test] + fn initial_strategy_small_library_keeps_cheapest_n1() { + // Below threshold, not flagged unreliable → unchanged N1-first chain. + let s = IngestStrategy::select_initial_strategy(navidrome_full(), Some(1_000), false); + assert_eq!(s, IngestStrategy::N1); + } + + #[test] + fn initial_strategy_large_library_avoids_n1_for_s1() { + // Over threshold → S1 even though N1 is advertised (deep-offset wall). + let s = IngestStrategy::select_initial_strategy(navidrome_full(), Some(170_000), false); + assert_eq!(s, IngestStrategy::S1); + } + + #[test] + fn initial_strategy_unreliable_flag_avoids_n1_regardless_of_size() { + // Learned `n1_bulk_unreliable` forces the non-N1 path even when the + // count is small/unknown (covers R7-15 "unknown → large if N1 failed"). + let small = + IngestStrategy::select_initial_strategy(navidrome_full(), Some(500), true); + assert_eq!(small, IngestStrategy::S1); + let unknown = IngestStrategy::select_initial_strategy(navidrome_full(), None, true); + assert_eq!(unknown, IngestStrategy::S1); + } + + #[test] + fn initial_strategy_large_without_search3_falls_back_to_s2() { + // Avoid-N1 path but no search3 bulk → universal album crawl, not N1. + let flags = CapabilityFlags::new(CapabilityFlags::NAVIDROME_NATIVE_BULK); + let s = IngestStrategy::select_initial_strategy(flags, Some(170_000), false); + assert_eq!(s, IngestStrategy::S2); + } + + #[test] + fn initial_strategy_unknown_count_uses_cheapest_when_not_flagged() { + // First run, no count yet, N1 never failed → try cheapest (N1); the + // mid-run N1→S1 fallback (R7-15 Q5) handles the wall if hit. + let s = IngestStrategy::select_initial_strategy(navidrome_full(), None, false); + assert_eq!(s, IngestStrategy::N1); + } + + #[test] + fn initial_strategy_threshold_is_strictly_greater_than() { + // Exactly at the threshold is not "large"; one above is. + let at = IngestStrategy::select_initial_strategy( + navidrome_full(), + Some(LARGE_LIBRARY_THRESHOLD), + false, + ); + assert_eq!(at, IngestStrategy::N1); + let over = IngestStrategy::select_initial_strategy( + navidrome_full(), + Some(LARGE_LIBRARY_THRESHOLD + 1), + false, + ); + assert_eq!(over, IngestStrategy::S1); + } + + #[test] + fn tag_roundtrip_is_stable_for_cursor_persistence() { + for s in [ + IngestStrategy::N1, + IngestStrategy::S1, + IngestStrategy::S2, + IngestStrategy::S3, + ] { + assert_eq!(IngestStrategy::from_tag(s.as_tag()), Some(s)); + } + assert_eq!(IngestStrategy::from_tag("unknown"), None); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/supervisor.rs b/src-tauri/crates/psysonic-library/src/sync/supervisor.rs new file mode 100644 index 00000000..5d2c8e5c --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/supervisor.rs @@ -0,0 +1,166 @@ +//! C5 — `SyncSupervisor`. Wraps the spawn / cancel / join lifecycle +//! the top crate (PR-5) will use to run an `InitialSyncRunner` or +//! `DeltaSyncRunner` from a Tauri command. The supervisor owns the +//! cancellation `AtomicBool` and the `mpsc` receiver carrying +//! progress events. +//! +//! Stays pure library — no Tauri imports here; PR-5 hooks the +//! receiver to `AppHandle::emit`. + +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use super::error::SyncError; +use super::progress::{ChannelProgress, Progress, ProgressEvent}; + +pub struct SyncSupervisor { + cancel: Arc, + handle: Option>>, + progress_rx: Option>, +} + +impl SyncSupervisor { + /// Spawn a sync workload. `task` receives the cancellation flag + /// and a `Progress` handle backed by an internal mpsc channel. + /// Caller drives the receiver via `progress_receiver()` and waits + /// for completion via `join().await`. + pub fn spawn(task: F) -> Self + where + F: FnOnce(Arc, Arc) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + { + Self::spawn_with_interval(task, ChannelProgress::DEFAULT_INTERVAL) + } + + /// Variant that overrides the throttle interval — tests pass + /// `Duration::ZERO` so every event observed in + /// `progress_receiver()` matches the runner's emit order. + pub fn spawn_with_interval(task: F, throttle: std::time::Duration) -> Self + where + F: FnOnce(Arc, Arc) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + { + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let progress: Arc = + Arc::new(ChannelProgress::with_interval(tx, throttle)); + let cancel_clone = Arc::clone(&cancel); + let handle = tokio::task::spawn(async move { task(cancel_clone, progress).await }); + Self { + cancel, + handle: Some(handle), + progress_rx: Some(rx), + } + } + + /// Trip the cancellation flag. Runners check it between batches + /// and bail out with `SyncError::Cancelled` on the next iteration. + pub fn cancel(&self) { + self.cancel.store(true, Ordering::SeqCst); + } + + pub fn is_cancelled(&self) -> bool { + self.cancel.load(Ordering::SeqCst) + } + + /// Take the progress receiver. Single consumer only — the + /// `Option::take` semantics mean later calls return `None`. + pub fn progress_receiver( + &mut self, + ) -> Option> { + self.progress_rx.take() + } + + /// Wait for the spawned task. Returns the task's `Result` — + /// `Ok(Ok(()))` on clean exit, `Ok(Err(SyncError))` on a + /// runner-level failure, `Err(JoinError)` only if the task + /// panicked, in which case we surface that as `SyncError::Storage` + /// so callers never need to know about tokio internals. + pub async fn join(mut self) -> Result<(), SyncError> { + let handle = self + .handle + .take() + .ok_or_else(|| SyncError::Storage("supervisor already joined".into()))?; + match handle.await { + Ok(inner) => inner, + Err(join_err) => Err(SyncError::Storage(format!( + "sync task panicked: {join_err}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread")] + async fn supervisor_runs_task_to_completion_and_emits_progress() { + let mut sup = SyncSupervisor::spawn_with_interval( + |_cancel, progress| async move { + progress.emit(ProgressEvent::PhaseChanged { phase: "ingest".into() }); + progress.emit(ProgressEvent::Completed { + kind: "initial_sync".into(), + }); + Ok(()) + }, + Duration::ZERO, + ); + let mut rx = sup.progress_receiver().expect("receiver still in place"); + let result = sup.join().await; + assert!(result.is_ok()); + let mut events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + assert_eq!(events.len(), 2); + assert!(matches!(events[0], ProgressEvent::PhaseChanged { .. })); + assert!(matches!(events[1], ProgressEvent::Completed { .. })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn supervisor_cancel_trips_atomic_flag_before_task_joins() { + let started = Arc::new(tokio::sync::Notify::new()); + let started_clone = Arc::clone(&started); + let sup = SyncSupervisor::spawn(move |cancel, _progress| { + let started = started_clone; + async move { + started.notify_one(); + // Spin until cancelled or 2s timeout. + for _ in 0..200 { + if cancel.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Ok(()) + } + }); + started.notified().await; + sup.cancel(); + assert!(sup.is_cancelled()); + let result = sup.join().await; + assert!(matches!(result, Err(SyncError::Cancelled))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn supervisor_propagates_task_panic_as_sync_error() { + let sup = SyncSupervisor::spawn(|_cancel, _progress| async move { + panic!("simulated task panic"); + }); + let result = sup.join().await; + assert!(matches!(result, Err(SyncError::Storage(_)))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn progress_receiver_take_returns_none_after_first_call() { + let mut sup = SyncSupervisor::spawn(|_cancel, _progress| async move { Ok(()) }); + let first = sup.progress_receiver(); + let second = sup.progress_receiver(); + assert!(first.is_some()); + assert!(second.is_none()); + sup.join().await.unwrap(); + } +} diff --git a/src-tauri/crates/psysonic-library/src/sync/tombstone.rs b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs new file mode 100644 index 00000000..6c5008f5 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs @@ -0,0 +1,447 @@ +//! C4 — `TombstoneReconciler` (spec §6.7). +//! +//! Streams a chunk of local track ids, hits `getSong` per id, and +//! marks `track.deleted = 1` for every `SubsonicError::NotFound` +//! (error code 70). Designed for two callers: +//! +//! - **Mode A (manual integrity check):** Settings → "Verify library +//! integrity" loops `reconcile_chunk(N)` until it returns +//! `checked == 0`. +//! - **Mode B (auto, threshold-triggered):** the delta scheduler +//! tests `should_auto_reconcile` against the count drop, then loops +//! `reconcile_chunk(budget)` once per delta tick until the gap +//! closes. +//! +//! Streaming so memory stays bounded at 500k: `LIMIT N ORDER BY +//! synced_at ASC` picks the next chunk; PR-3c keeps the loop entirely +//! caller-driven so cancellation is checked between chunks. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use psysonic_integration::subsonic::{SubsonicClient, SubsonicError}; + +use super::backoff::{jitter_salt, with_jitter, Backoff}; +use super::error::SyncError; +use crate::store::LibraryStore; + +const MAX_ATTEMPTS_PER_BATCH: u32 = 5; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TombstoneReport { + pub checked: u32, + pub deleted: u32, +} + +pub struct TombstoneReconciler<'a> { + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: String, + cancel: Option>, + sleep_enabled: bool, +} + +impl<'a> TombstoneReconciler<'a> { + pub fn new( + store: &'a LibraryStore, + subsonic: &'a SubsonicClient, + server_id: impl Into, + ) -> Self { + Self { + store, + subsonic, + server_id: server_id.into(), + cancel: None, + sleep_enabled: true, + } + } + + pub fn with_cancellation(mut self, flag: Arc) -> Self { + self.cancel = Some(flag); + self + } + + pub fn with_sleep_disabled(mut self) -> Self { + self.sleep_enabled = false; + self + } + + /// Process up to `budget` not-yet-checked tracks. Returns counts + /// for this call only — caller loops until `checked == 0` to + /// complete a Mode A pass, or stops at any budget for Mode B + /// sampled passes. Order: oldest `synced_at` first so the most + /// stale rows get re-validated soonest. + pub async fn reconcile_chunk(&self, budget: u32) -> Result { + if budget == 0 { + return Ok(TombstoneReport::default()); + } + let ids = self.next_candidates(budget)?; + let mut report = TombstoneReport::default(); + for id in ids { + self.check_cancellation()?; + report.checked = report.checked.saturating_add(1); + let outcome = retry_with_backoff( + self, + || self.subsonic.get_song(&id), + |e: SubsonicError| -> SyncError { e.into() }, + ) + .await; + match outcome { + Ok(_) => { + // Still present — stamp `synced_at` so it goes to + // the back of the queue and we don't re-probe it + // again on the next chunk. + self.mark_synced(&id)?; + } + Err(SyncError::NotFound) => { + self.mark_deleted(&id)?; + report.deleted = report.deleted.saturating_add(1); + } + Err(other) => return Err(other), + } + } + Ok(report) + } + + fn check_cancellation(&self) -> Result<(), SyncError> { + if let Some(flag) = &self.cancel { + if flag.load(Ordering::SeqCst) { + return Err(SyncError::Cancelled); + } + } + Ok(()) + } + + fn next_candidates(&self, budget: u32) -> Result, SyncError> { + self.store + .with_conn("misc", |c| { + let mut stmt = c.prepare( + "SELECT id FROM track \ + WHERE server_id = ?1 AND deleted = 0 \ + ORDER BY synced_at ASC LIMIT ?2", + )?; + let rows: rusqlite::Result> = stmt + .query_map(rusqlite::params![self.server_id, budget as i64], |r| { + r.get::<_, String>(0) + })? + .collect(); + rows + }) + .map_err(SyncError::Storage) + } + + fn mark_deleted(&self, id: &str) -> Result<(), SyncError> { + self.store + .with_conn("misc", |c| { + c.execute( + "UPDATE track SET deleted = 1, synced_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + rusqlite::params![self.server_id, id, now_unix_ms()], + )?; + Ok(()) + }) + .map_err(SyncError::Storage) + } + + fn mark_synced(&self, id: &str) -> Result<(), SyncError> { + self.store + .with_conn("misc", |c| { + c.execute( + "UPDATE track SET synced_at = ?3 \ + WHERE server_id = ?1 AND id = ?2", + rusqlite::params![self.server_id, id, now_unix_ms()], + )?; + Ok(()) + }) + .map_err(SyncError::Storage) + } + + async fn sleep(&self, d: Duration) { + if self.sleep_enabled && !d.is_zero() { + tokio::time::sleep(d).await; + } + } +} + +/// §6.7 Mode B threshold check — returns `true` when the local / +/// server count gap exceeds the configured percentage. `server_count +/// == 0` is treated as "no signal" → `false` (no spurious reconcile +/// on a fresh server response). +pub fn should_auto_reconcile(local_count: u32, server_count: u32, threshold_pct: u32) -> bool { + if server_count == 0 { + return false; + } + let gap = local_count.saturating_sub(server_count); + let ratio_x100 = gap.saturating_mul(100) / server_count; + ratio_x100 > threshold_pct +} + +fn now_unix_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +async fn retry_with_backoff<'a, F, FFut, T, E>( + reconciler: &TombstoneReconciler<'a>, + mut build: F, + map_err: impl Fn(E) -> SyncError, +) -> Result +where + F: FnMut() -> FFut, + FFut: std::future::Future>, +{ + let mut backoff = Backoff::default(); + let mut attempt = 0u32; + loop { + reconciler.check_cancellation()?; + attempt += 1; + match build().await { + Ok(v) => return Ok(v), + Err(e) => { + let mapped = map_err(e); + if !is_retryable(&mapped) || attempt >= MAX_ATTEMPTS_PER_BATCH { + return Err(mapped); + } + let delay = backoff.next_delay(); + let jittered = with_jitter(delay, jitter_salt(attempt)); + reconciler.sleep(jittered).await; + } + } + } +} + +fn is_retryable(e: &SyncError) -> bool { + matches!(e, SyncError::Transport(_) | SyncError::Navidrome(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials}; + use serde_json::json; + use wiremock::matchers::{method as wm_method, path as wm_path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_subsonic(uri: &str) -> SubsonicClient { + SubsonicClient::with_static_credentials( + uri, + SubsonicCredentials::with_static("user", "tok", "salt"), + reqwest::Client::new(), + ) + } + + fn seed_track(store: &LibraryStore, id: &str, synced_at: i64) { + TrackRepository::new(store) + .upsert_batch(&[TrackRow { + server_id: "s1".into(), + id: id.into(), + title: id.into(), + title_sort: None, + artist: None, + artist_id: None, + album: String::new(), + album_id: None, + album_artist: None, + duration_sec: 0, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at, + raw_json: "{}".into(), + }]) + .unwrap(); + } + + // ── should_auto_reconcile threshold predicate ───────────────────── + + #[test] + fn threshold_fires_when_local_outpaces_server_above_pct() { + // 110 local vs 100 server → 10% gap > 5% threshold. + assert!(should_auto_reconcile(110, 100, 5)); + } + + #[test] + fn threshold_stays_silent_within_tolerance() { + // 102 local vs 100 server → 2% gap, threshold 5%. + assert!(!should_auto_reconcile(102, 100, 5)); + } + + #[test] + fn threshold_silent_when_local_is_below_or_equal_server() { + assert!(!should_auto_reconcile(100, 100, 0)); + assert!(!should_auto_reconcile(50, 100, 5)); + } + + #[test] + fn threshold_silent_when_server_count_is_zero() { + // No signal — never reconcile on a server that's still scanning. + assert!(!should_auto_reconcile(1000, 0, 5)); + } + + // ── reconcile_chunk marks deleted on code 70 ───────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_marks_deleted_for_code_70() { + let server = MockServer::start().await; + // tr_a → still present, tr_b → 404 via code 70. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_a")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { "id": "tr_a", "title": "Still here" } + } + }))) + .mount(&server) + .await; + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .and(query_param("id", "tr_b")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "failed", + "error": { "code": 70, "message": "Song not found" } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + seed_track(&store, "tr_a", 1); + seed_track(&store, "tr_b", 2); + + let subsonic = test_subsonic(&server.uri()); + let report = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_sleep_disabled() + .reconcile_chunk(10) + .await + .unwrap(); + + assert_eq!(report.checked, 2); + assert_eq!(report.deleted, 1); + + // tr_b is marked deleted; tr_a stays live but its synced_at is + // refreshed (so it doesn't get re-picked immediately). + let (a_deleted, b_deleted): (i64, i64) = store + .with_conn("misc", |c| { + let a: i64 = c.query_row( + "SELECT deleted FROM track WHERE id='tr_a'", + [], + |r| r.get(0), + )?; + let b: i64 = c.query_row( + "SELECT deleted FROM track WHERE id='tr_b'", + [], + |r| r.get(0), + )?; + Ok((a, b)) + }) + .unwrap(); + assert_eq!(a_deleted, 0); + assert_eq!(b_deleted, 1); + } + + // ── reconcile_chunk respects budget and ordering ───────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_processes_oldest_first_up_to_budget() { + let server = MockServer::start().await; + // Any id → ok envelope. + Mock::given(wm_method("GET")) + .and(wm_path("/rest/getSong.view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "subsonic-response": { + "status": "ok", + "song": { "id": "any", "title": "t" } + } + }))) + .mount(&server) + .await; + + let store = LibraryStore::open_in_memory(); + // Seed three tracks with distinct synced_at values; oldest first. + seed_track(&store, "tr_oldest", 100); + seed_track(&store, "tr_middle", 200); + seed_track(&store, "tr_newest", 300); + + let subsonic = test_subsonic(&server.uri()); + let report = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_sleep_disabled() + .reconcile_chunk(2) + .await + .unwrap(); + assert_eq!(report.checked, 2); + + // After the chunk: the two checked rows have a refreshed + // synced_at; the un-checked tr_newest still sits at 300. + let untouched: i64 = store + .with_conn("misc", |c| c.query_row( + "SELECT synced_at FROM track WHERE id='tr_newest'", + [], + |r| r.get(0), + )) + .unwrap(); + assert_eq!(untouched, 300, "tr_newest must not be probed within budget=2"); + } + + // ── reconcile_chunk: empty store ─────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_returns_zero_counts_on_empty_store() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + let subsonic = test_subsonic(&server.uri()); + let report = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_sleep_disabled() + .reconcile_chunk(50) + .await + .unwrap(); + assert_eq!(report.checked, 0); + assert_eq!(report.deleted, 0); + } + + // ── reconcile_chunk: cancellation ───────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_chunk_returns_cancelled_when_flag_tripped() { + let server = MockServer::start().await; + let store = LibraryStore::open_in_memory(); + seed_track(&store, "tr_x", 1); + + let flag = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let subsonic = test_subsonic(&server.uri()); + let err = TombstoneReconciler::new(&store, &subsonic, "s1") + .with_cancellation(flag) + .with_sleep_disabled() + .reconcile_chunk(10) + .await + .unwrap_err(); + assert!(matches!(err, SyncError::Cancelled)); + } +} diff --git a/src-tauri/crates/psysonic-library/src/track_fts.rs b/src-tauri/crates/psysonic-library/src/track_fts.rs new file mode 100644 index 00000000..98f6279a --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/track_fts.rs @@ -0,0 +1,156 @@ +//! FTS5 maintenance for bulk initial-sync ingest. +//! +//! Per-row FTS triggers dominate N1/S1 write time at scale. IS-3 suspends +//! them, loads tracks without FTS maintenance, then rebuilds from the +//! external-content table once before restoring triggers. + +use rusqlite::Connection; + +const RESTORE_TRACK_FTS_TRIGGERS: &str = r#" +CREATE TRIGGER IF NOT EXISTS track_ai AFTER INSERT ON track BEGIN + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; + +CREATE TRIGGER IF NOT EXISTS track_ad AFTER DELETE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); +END; + +CREATE TRIGGER IF NOT EXISTS track_au AFTER UPDATE ON track BEGIN + INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre); + INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre) + VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre); +END; +"#; + +/// Drop FTS sync triggers so bulk upserts skip per-row FTS maintenance. +pub fn suspend_track_fts_triggers(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "DROP TRIGGER IF EXISTS track_ai; + DROP TRIGGER IF EXISTS track_ad; + DROP TRIGGER IF EXISTS track_au;", + ) +} + +/// Rebuild `track_fts` from the `track` content table (fts5 external content). +pub fn rebuild_track_fts_from_content(conn: &Connection) -> rusqlite::Result<()> { + conn.execute("INSERT INTO track_fts(track_fts) VALUES('rebuild')", [])?; + Ok(()) +} + +/// Restore FTS sync triggers after bulk ingest + rebuild. +pub fn restore_track_fts_triggers(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(RESTORE_TRACK_FTS_TRIGGERS) +} + +/// Normalize trigger DDL for equality checks — strips `IF NOT EXISTS` +/// and collapses whitespace so migration vs restore sources compare cleanly. +#[cfg(test)] +pub(crate) fn normalize_trigger_ddl(sql: &str) -> String { + sql.replace("IF NOT EXISTS", "") + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + use std::collections::HashMap; + + const TRACK_FTS_TRIGGER_NAMES: [&str; 3] = ["track_ai", "track_ad", "track_au"]; + + fn fetch_track_fts_trigger_ddl(conn: &Connection, name: &str) -> String { + conn.query_row( + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + [name], + |r| r.get(0), + ) + .unwrap_or_else(|e| panic!("missing trigger `{name}`: {e}")) + } + + fn migration_track_fts_trigger_bodies( + conn: &Connection, + ) -> rusqlite::Result> { + Ok(TRACK_FTS_TRIGGER_NAMES + .iter() + .map(|name| { + let ddl = fetch_track_fts_trigger_ddl(conn, name); + (name.to_string(), normalize_trigger_ddl(&ddl)) + }) + .collect()) + } + + #[test] + fn restore_track_fts_triggers_match_migration_bodies() { + let store = LibraryStore::open_in_memory(); + let baseline = store + .with_conn("misc", migration_track_fts_trigger_bodies) + .unwrap(); + + store + .with_conn_mut("misc", |conn| { + suspend_track_fts_triggers(conn)?; + restore_track_fts_triggers(conn)?; + for name in TRACK_FTS_TRIGGER_NAMES { + let after = normalize_trigger_ddl(&fetch_track_fts_trigger_ddl(conn, name)); + assert_eq!( + after, + baseline[name], + "trigger `{name}` body drifted after suspend/restore" + ); + } + Ok(()) + }) + .unwrap(); + } + + #[test] + fn bulk_ingest_suspend_rebuild_restores_fts_search() { + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("misc", |conn| suspend_track_fts_triggers(conn)) + .unwrap(); + store + .with_conn_mut("misc", |conn| { + conn.execute( + "INSERT INTO track (server_id, id, title, album, duration_sec, deleted, synced_at, raw_json) \ + VALUES ('s1', 't1', 'Bulk Title', 'Album', 1, 0, 1, '{}')", + [], + ) + }) + .unwrap(); + let mid: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'Bulk'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(mid, 0, "FTS must not update while triggers are suspended"); + + store + .with_conn_mut("misc", |conn| { + rebuild_track_fts_from_content(conn)?; + restore_track_fts_triggers(conn) + }) + .unwrap(); + + let after: i64 = store + .with_conn("misc", |c| { + c.query_row( + "SELECT COUNT(*) FROM track_fts WHERE track_fts MATCH 'Bulk'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(after, 1); + } +} diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index 8ff6625a..6ad7c1ee 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -40,9 +40,10 @@ pub async fn download_track_hot_cache( // prefetch worker runs invokes sequentially. let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); return Ok(HotCacheDownloadResult { path: path_str, @@ -79,9 +80,10 @@ pub async fn download_track_hot_cache( let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); let size = tokio::fs::metadata(&file_path) @@ -134,9 +136,10 @@ pub async fn promote_stream_cache_to_hot_cache( ); let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } @@ -151,7 +154,7 @@ pub async fn promote_stream_cache_to_hot_cache( .await .map_err(|e| e.to_string())?; - let _ = enqueue_analysis_seed(&app, &track_id, &bytes).await; + let _ = enqueue_analysis_seed(&app, &server_id, &track_id, &bytes).await; let size = tokio::fs::metadata(&file_path) .await @@ -176,9 +179,10 @@ pub async fn promote_stream_cache_to_hot_cache( } let app_seed = app.clone(); let tid = track_id.clone(); + let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; }); let size = tokio::fs::metadata(&file_path) .await diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs index 2afcab82..1ca843b0 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs @@ -13,15 +13,16 @@ use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; pub async fn enqueue_analysis_seed_from_file( app: &tauri::AppHandle, + server_id: &str, track_id: &str, file_path: &std::path::Path, ) { let cache = app.try_state::(); let cache_ref: Option<&analysis_cache::AnalysisCache> = cache.as_ref().map(|s| s.inner()); - let Some(bytes) = read_seed_bytes_if_needed(cache_ref, track_id, file_path).await else { + let Some(bytes) = read_seed_bytes_if_needed(cache_ref, server_id, track_id, file_path).await else { return; }; - let _ = enqueue_analysis_seed(app, track_id, &bytes).await; + let _ = enqueue_analysis_seed(app, server_id, track_id, &bytes).await; } /// AppHandle-free decision: returns the file bytes when seeding is required @@ -32,11 +33,12 @@ pub async fn enqueue_analysis_seed_from_file( /// branch with `AnalysisCache::open_in_memory()` plus a `tempfile::TempDir`. pub(crate) async fn read_seed_bytes_if_needed( cache: Option<&analysis_cache::AnalysisCache>, + server_id: &str, track_id: &str, file_path: &std::path::Path, ) -> Option> { if let Some(cache) = cache { - if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) { + if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) { return None; } } @@ -162,7 +164,7 @@ pub async fn download_track_offline( ) .await?; - enqueue_analysis_seed_from_file(&app, &track_id, &final_path).await; + enqueue_analysis_seed_from_file(&app, &server_id, &track_id, &final_path).await; Ok(path_str) } @@ -384,6 +386,7 @@ mod tests { // cpu_seed_redundant_for_track returns true when both waveform AND // loudness rows exist for the current algo version. let key = TrackKey { + server_id: String::new(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), }; @@ -420,7 +423,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("track.mp3"); std::fs::write(&file, b"audio data").unwrap(); - let bytes = read_seed_bytes_if_needed(None, "anything", &file).await; + let bytes = read_seed_bytes_if_needed(None, "", "anything", &file).await; assert_eq!(bytes.as_deref(), Some(b"audio data".as_slice())); } @@ -430,7 +433,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("track.flac"); std::fs::write(&file, b"some bytes").unwrap(); - let bytes = read_seed_bytes_if_needed(Some(&cache), "fresh-track", &file).await; + let bytes = read_seed_bytes_if_needed(Some(&cache), "", "fresh-track", &file).await; assert_eq!(bytes.as_deref(), Some(b"some bytes".as_slice())); } @@ -443,7 +446,7 @@ mod tests { let file = dir.path().join("track.mp3"); std::fs::write(&file, b"would-be-decoded bytes").unwrap(); - let bytes = read_seed_bytes_if_needed(Some(&cache), "redundant-track", &file).await; + let bytes = read_seed_bytes_if_needed(Some(&cache), "", "redundant-track", &file).await; assert!( bytes.is_none(), "redundant-cache short-circuit must skip the file read entirely" @@ -454,7 +457,7 @@ mod tests { async fn read_seed_bytes_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let phantom = dir.path().join("never-written.mp3"); - let bytes = read_seed_bytes_if_needed(None, "any", &phantom).await; + let bytes = read_seed_bytes_if_needed(None, "", "any", &phantom).await; assert!(bytes.is_none()); } @@ -463,7 +466,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("empty.flac"); std::fs::write(&file, b"").unwrap(); - let bytes = read_seed_bytes_if_needed(None, "any", &file).await; + let bytes = read_seed_bytes_if_needed(None, "", "any", &file).await; assert!(bytes.is_none(), "empty file must not trigger seeding"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b62e56a..e92746e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,97 @@ pub fn run() { app.manage(cache); } + // ── Library track store (psysonic-library, PR-5a + PR-5b) ───── + // PR-5a brought up the read-only Tauri surface + LibraryRuntime. + // PR-5b adds the mutating commands, sync session map, current-job + // tracker, and the 30-second background scheduler tick task below + // — which sweeps every bound session through + // `BackgroundScheduler::tick` while honouring the runtime's + // `scheduler_cancel` flag. + { + let store = psysonic_library::store::LibraryStore::init(app.handle()) + .map_err(|e| format!("library store init failed: {e}"))?; + let runtime = psysonic_library::LibraryRuntime::new(std::sync::Arc::new(store)); + app.manage(runtime); + + let app_for_sched = app.handle().clone(); + tauri::async_runtime::spawn(async move { + use std::sync::atomic::Ordering; + use std::time::Duration; + use tokio::time::MissedTickBehavior; + + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Some(state) = app_for_sched + .try_state::() + else { + break; + }; + if state.scheduler_cancel.load(Ordering::SeqCst) { + break; + } + let sessions = state.snapshot_sessions(); + if sessions.is_empty() { + continue; + } + let hint = state.current_playback_hint(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0); + for session in sessions { + let scope = session.library_scope.clone().unwrap_or_default(); + let flags_bits = psysonic_library::repos::SyncStateRepository::new( + &state.store, + ) + .get_capability_flags(&session.server_id, &scope) + .ok() + .flatten() + .unwrap_or(0); + let flags = psysonic_library::sync::capability::CapabilityFlags::new( + flags_bits, + ); + let subsonic = psysonic_integration::subsonic::SubsonicClient::new( + session.base_url.clone(), + session.username.clone(), + session.password.clone(), + ); + let mut sched = + psysonic_library::sync::scheduler::BackgroundScheduler::new( + &state.store, + &subsonic, + session.server_id.clone(), + scope.clone(), + flags, + ) + .with_playback_hint(hint); + if let Some(tok) = session.navidrome_token.clone() { + sched = sched.with_navidrome_credentials( + psysonic_library::sync::capability::NavidromeProbeCredentials { + server_url: session.base_url.clone(), + bearer_token: tok, + }, + ); + } + let foreground_job = state + .current_job() + .is_some_and(|j| j.server_id == session.server_id); + if foreground_job { + sched = sched.with_foreground_sync_job_active(true); + } + let _ = sched.tick(now_ms).await; + // Background ticks stay silent in PR-5b — Tauri + // emit for the scheduler path lands when the + // Settings panel needs it (PR-5c). Manual + // `library_sync_start` already emits via its + // own orchestrator. + } + } + }); + } + audio::cleanup_orphan_stream_spill_dir(app.handle()); // ── Playback-query port (analysis → audio back-edge) ────────── @@ -127,6 +218,62 @@ pub fn run() { app.manage(handle); } + // ── Content-hash sink (analysis → library E2 back-edge) ─────── + // After a seed the analysis pipeline records the playback-derived + // md5_16kb as `track.content_hash` so id-remap can rebind a track + // when the server reassigns ids. Decoupled from psysonic-library + // via a psysonic-core port; a no-op when the library has no row for + // the (server_id, track_id) — i.e. the index is off for that server. + { + let app_for_hash = app.handle().clone(); + let sink = psysonic_core::ports::ContentHashSink::new( + move |server_id: &str, track_id: &str, md5: &str| { + if let Some(runtime) = + app_for_hash.try_state::() + { + let _ = psysonic_library::commands::patch_content_hash( + &runtime, server_id, track_id, md5, + ); + } + }, + ); + app.manage(sink); + } + + // ── Analysis-readiness query (library → analysis E3 back-edge) ── + // `library_get_track` enrichment asks whether waveform/loudness are + // cached for (server_id, track_id, content_hash). Read-only probe: + // exact key then legacy '' fallback, no re-tag. Decoupled from + // psysonic-analysis via a psysonic-core port. + { + let app_for_readiness = app.handle().clone(); + let query = psysonic_core::ports::AnalysisReadinessQuery::new( + move |server_id: &str, track_id: &str, md5: &str| { + let Some(cache) = app_for_readiness + .try_state::() + else { + return (false, false); + }; + let probe = |sid: &str| { + let key = analysis_cache::TrackKey { + server_id: sid.to_string(), + track_id: track_id.to_string(), + md5_16kb: md5.to_string(), + }; + let wf = cache.get_waveform(&key).ok().flatten().is_some(); + let ld = cache.loudness_row_exists_for_key(&key).unwrap_or(false); + (wf, ld) + }; + let (wf, ld) = probe(server_id); + // Legacy '' fallback for rows analysed before E1 wiring. + let wf = wf || (!server_id.is_empty() && probe("").0); + let ld = ld || (!server_id.is_empty() && probe("").1); + (wf, ld) + }, + ); + app.manage(query); + } + // Periodic analysis queue sizes (debug logging mode only). tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop()); @@ -426,6 +573,29 @@ pub fn run() { psysonic_analysis::commands::analysis_delete_all_waveforms, psysonic_analysis::commands::analysis_enqueue_seed_from_url, psysonic_analysis::commands::analysis_prune_pending_to_track_ids, + psysonic_library::commands::library_get_status, + psysonic_library::commands::library_search, + psysonic_library::commands::library_live_search, + psysonic_library::commands::library_advanced_search, + psysonic_library::commands::library_search_cross_server, + psysonic_library::commands::library_get_track, + psysonic_library::commands::library_get_tracks_batch, + psysonic_library::commands::library_get_tracks_by_album, + psysonic_library::commands::library_get_artifact, + psysonic_library::commands::library_get_facts, + psysonic_library::commands::library_get_offline_path, + psysonic_library::commands::library_sync_bind_session, + psysonic_library::commands::library_sync_clear_session, + psysonic_library::commands::library_set_playback_hint, + psysonic_library::commands::library_get_playback_hint, + psysonic_library::commands::library_sync_start, + psysonic_library::commands::library_sync_verify_integrity, + psysonic_library::commands::library_sync_cancel, + psysonic_library::commands::library_patch_track, + psysonic_library::commands::library_put_artifact, + psysonic_library::commands::library_put_fact, + psysonic_library::commands::library_purge_server, + psysonic_library::commands::library_delete_server_data, psysonic_syncfs::cache::offline::download_track_offline, psysonic_syncfs::cache::offline::cancel_offline_downloads, psysonic_syncfs::cache::offline::clear_offline_cancel, diff --git a/src/api/library.ts b/src/api/library.ts new file mode 100644 index 00000000..f0b79bf8 --- /dev/null +++ b/src/api/library.ts @@ -0,0 +1,520 @@ +/** + * Typed wrappers around the `library_*` Tauri commands (spec §7.1) plus + * subscribers for `library:sync-progress` / `library:sync-idle` events + * (§7.2). One thin file per cucadmuh's PR-5 kickoff Q1 — Settings UI + * (LibraryTab) imports from here; nothing else in the app talks to the + * backend library surface directly. + */ + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─ + +export interface TrackRefDto { + serverId: string; + trackId: string; + contentHash?: string | null; +} + +/** E3 readiness summary — present only on single-track `libraryGetTrack` reads. */ +export interface TrackEnrichmentDto { + waveformReady: boolean; + loudnessReady: boolean; + lyricsCached: boolean; +} + +export interface LibraryTrackDto { + serverId: string; + id: string; + contentHash?: string | null; + title: string; + titleSort?: string | null; + artist?: string | null; + artistId?: string | null; + album: string; + albumId?: string | null; + albumArtist?: string | null; + durationSec: number; + trackNumber?: number | null; + discNumber?: number | null; + year?: number | null; + genre?: string | null; + suffix?: string | null; + bitRate?: number | null; + sizeBytes?: number | null; + coverArtId?: string | null; + starredAt?: number | null; + userRating?: number | null; + playCount?: number | null; + playedAt?: number | null; + serverPath?: string | null; + libraryId?: string | null; + isrc?: string | null; + mbidRecording?: string | null; + bpm?: number | null; + replayGainTrackDb?: number | null; + replayGainAlbumDb?: number | null; + serverUpdatedAt?: number | null; + serverCreatedAt?: number | null; + syncedAt: number; + /** E3: populated only by `libraryGetTrack` (omitted on list/batch reads). */ + enrichment?: TrackEnrichmentDto | null; + rawJson: unknown; +} + +export interface SyncStateDto { + serverId: string; + libraryScope: string; + syncPhase: string; + capabilityFlags: number; + libraryTier: string; + lastFullSyncAt?: number | null; + lastDeltaSyncAt?: number | null; + nextPollAt?: number | null; + serverLastScanIso?: string | null; + indexesLastModifiedMs?: number | null; + artistsLastModifiedMs?: number | null; + localTrackCount?: number | null; + serverTrackCount?: number | null; + lastError?: string | null; + localTracksMaxUpdatedMs?: number | null; + /** True when at least one non-deleted track exists locally (cheap EXISTS). */ + hasLocalTracks?: boolean; + ingestStrategy?: string | null; + ingestPhase?: string | null; + /** Tracks ingested per persisted initial-sync cursor (IS-3 progress). */ + cursorIngestedCount?: number | null; + n1BulkUnreliable?: boolean | null; +} + +export interface LibraryTracksEnvelope { + tracks: LibraryTrackDto[]; + total: number; +} + +export interface TrackArtifactDto { + serverId: string; + trackId: string; + artifactKind: string; + format: string; + sourceKind: string; + sourceId: string; + language?: string | null; + contentText?: string | null; + contentBytes: number; + notFound: boolean; + contentHash?: string | null; + fetchedAt: number; + expiresAt?: number | null; +} + +export interface ArtifactInputDto { + artifactKind: string; + format: string; + sourceKind: string; + sourceId: string; + language?: string | null; + contentText?: string | null; + contentBlob?: number[] | null; + contentBytes?: number; + notFound?: boolean; + contentHash?: string | null; + expiresAt?: number | null; +} + +export interface TrackFactDto { + serverId: string; + trackId: string; + factKind: string; + valueReal?: number | null; + valueInt?: number | null; + valueText?: string | null; + unit?: string | null; + sourceKind: string; + sourceId: string; + confidence: number; + contentHash?: string | null; + fetchedAt: number; + expiresAt?: number | null; +} + +export interface FactInputDto { + factKind: string; + valueReal?: number | null; + valueInt?: number | null; + valueText?: string | null; + unit?: string | null; + sourceKind: string; + sourceId: string; + confidence?: number; + contentHash?: string | null; + expiresAt?: number | null; +} + +export interface OfflinePathDto { + serverId: string; + trackId: string; + localPath?: string | null; + missing: boolean; +} + +export interface PurgeReportDto { + tracksDeleted: number; + albumsDeleted: number; + artistsDeleted: number; + offlineRowsDeleted: number; + bytesFreed: number; +} + +export interface SyncJobDto { + jobId: string; + serverId: string; + kind: string; // 'initial_sync' | 'delta_sync' +} + +// ── Advanced Search (PR-5d, §5.13 / §5.5B) ──────────────────────────── + +export type LibraryEntityType = 'artist' | 'album' | 'track'; + +/** v1 operator set the Rust `FilterFieldRegistry` accepts (§5.13.2). */ +export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true' | 'in'; + +export type SortDir = 'asc' | 'desc'; + +export interface LibraryFilterClause { + field: string; // registry id, e.g. 'genre' | 'year' | 'bpm' + op: FilterOperator; + value?: string | number | boolean | null; + valueTo?: number | null; // between: inclusive upper bound +} + +export interface LibrarySortClause { + field: string; + dir: SortDir; +} + +export interface LibraryAdvancedSearchRequest { + serverId: string; + libraryScope?: string | null; + query?: string | null; // shorthand → fts clause on text fields + entityTypes: LibraryEntityType[]; + filters?: LibraryFilterClause[]; + starredOnly?: boolean | null; + sort?: LibrarySortClause[]; + limit: number; + offset?: number; + /** Skip expensive COUNT queries (Live Search). */ + skipTotals?: boolean; +} + +export interface LibraryAlbumDto { + serverId: string; + id: string; + name: string; + artist?: string | null; + artistId?: string | null; + songCount?: number | null; + durationSec?: number | null; + year?: number | null; + genre?: string | null; + coverArtId?: string | null; + starredAt?: number | null; + syncedAt: number; + rawJson: unknown; +} + +export interface LibraryArtistDto { + serverId: string; + id: string; + name: string; + albumCount?: number | null; + syncedAt: number; + rawJson: unknown; +} + +export interface LibrarySearchTotals { + artists: number; + albums: number; + tracks: number; +} + +export interface LibraryAdvancedSearchResponse { + artists: LibraryArtistDto[]; + albums: LibraryAlbumDto[]; + tracks: LibraryTrackDto[]; + totals: LibrarySearchTotals; + /** Registry field ids actually applied — UI chips / debug. */ + appliedFilters: string[]; + source: 'local' | 'network' | 'mixed'; +} + +export interface LibraryCrossServerSearchResponse { + hits: LibraryTrackDto[]; + /** Fuzzy `title LIKE` matches the exact FTS pass missed (§5.9 / H3). */ + fuzzy: LibraryTrackDto[]; + serversSearched: string[]; +} + +// ── Read commands (PR-5a) ───────────────────────────────────────────── + +export function libraryGetStatus( + serverId: string, + libraryScope?: string, +): Promise { + return invoke('library_get_status', { serverId, libraryScope }); +} + +export function librarySearch( + serverId: string, + query: string, + options?: { limit?: number; offset?: number; libraryScope?: string }, +): Promise { + return invoke('library_search', { + serverId, + query, + limit: options?.limit, + offset: options?.offset, + libraryScope: options?.libraryScope, + }); +} + +/** + * Advanced Search against the local index (§5.13). The frontend fallback + * (PR-7 F2) decides local vs network and maps the same `LibraryFilterClause` + * shape onto the network path; this wrapper only talks to the local builder. + */ +export function libraryAdvancedSearch( + request: LibraryAdvancedSearchRequest, +): Promise { + return invoke('library_advanced_search', { request }); +} + +export interface LibraryLiveSearchResponse { + artists: LibraryArtistDto[]; + albums: LibraryAlbumDto[]; + tracks: LibraryTrackDto[]; + source: 'local' | 'network' | 'mixed'; +} + +/** Live Search dropdown — one lean FTS query (§5.9), not Advanced Search. */ +export interface LibraryLiveSearchRequest { + serverId: string; + query: string; + /** Subsonic `musicFolderId` / Navidrome library id — omit for all libraries. */ + libraryScope?: string | null; + artistLimit?: number; + albumLimit?: number; + songLimit?: number; + /** UI generation — stale Rust FTS passes are dropped server-side. */ + requestEpoch?: number; +} + +export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise { + return invoke('library_live_search', { request }); +} + +/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */ +export function librarySearchCrossServer(args: { + query: string; + limit?: number; + servers?: string[]; +}): Promise { + return invoke('library_search_cross_server', args); +} + +export function libraryGetTrack( + serverId: string, + trackId: string, +): Promise { + return invoke('library_get_track', { serverId, trackId }); +} + +export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { + return invoke('library_get_tracks_batch', { refs }); +} + +export function libraryGetTracksByAlbum( + serverId: string, + albumId: string, +): Promise { + return invoke('library_get_tracks_by_album', { serverId, albumId }); +} + +export function libraryGetArtifact( + serverId: string, + trackId: string, + artifactKind: string, + options?: { sourceKind?: string; sourceId?: string; format?: string }, +): Promise { + return invoke('library_get_artifact', { + serverId, + trackId, + artifactKind, + sourceKind: options?.sourceKind, + sourceId: options?.sourceId, + format: options?.format, + }); +} + +export function libraryGetFacts( + serverId: string, + trackId: string, + factKinds?: string[], +): Promise { + return invoke('library_get_facts', { serverId, trackId, factKinds }); +} + +export function libraryGetOfflinePath( + serverId: string, + trackId: string, +): Promise { + return invoke('library_get_offline_path', { serverId, trackId }); +} + +// ── Session + lifecycle (PR-5b) ─────────────────────────────────────── + +export function librarySyncBindSession(args: { + serverId: string; + baseUrl: string; + username: string; + password: string; + libraryScope?: string; +}): Promise { + return invoke('library_sync_bind_session', args); +} + +export function librarySyncClearSession(serverId: string): Promise { + return invoke('library_sync_clear_session', { serverId }); +} + +export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active'; + +export function libraryGetPlaybackHint(): Promise { + return invoke('library_get_playback_hint'); +} + +export function librarySetPlaybackHint(hint: PlaybackHint): Promise { + return invoke('library_set_playback_hint', { hint }); +} + +export type SyncMode = 'full' | 'delta'; + +export function librarySyncStart(args: { + serverId: string; + mode: SyncMode; + libraryScope?: string; +}): Promise { + return invoke('library_sync_start', args); +} + +/** Forced full-budget tombstone delta — Settings → «Verify integrity». */ +export function librarySyncVerifyIntegrity(args: { + serverId: string; + libraryScope?: string; +}): Promise { + return invoke('library_sync_verify_integrity', args); +} + +export function librarySyncCancel(jobId?: string): Promise { + return invoke('library_sync_cancel', { jobId }); +} + +export function libraryPatchTrack(args: { + serverId: string; + trackId: string; + patch: { + starredAt?: number | null; + userRating?: number | null; + playCount?: number | null; + playedAt?: number | null; + /** E2: playback-derived `md5_16kb` content fingerprint. Normally written + * by the Rust analysis bridge; exposed here for contract completeness. */ + contentHash?: string | null; + }; +}): Promise { + return invoke('library_patch_track', args); +} + +export function libraryPutArtifact(args: { + serverId: string; + trackId: string; + artifact: ArtifactInputDto; +}): Promise { + return invoke('library_put_artifact', args); +} + +export function libraryPutFact(args: { + serverId: string; + trackId: string; + fact: FactInputDto; +}): Promise { + return invoke('library_put_fact', args); +} + +export function libraryPurgeServer(args: { + serverId: string; + includeAnalysis?: boolean; + includeOffline?: boolean; +}): Promise { + return invoke('library_purge_server', args); +} + +export function libraryDeleteServerData(serverId: string): Promise { + return invoke('library_delete_server_data', { serverId }); +} + +// ── Event subscriptions ─────────────────────────────────────────────── + +export interface LibrarySyncProgressPayload { + serverId: string; + libraryScope: string; + /** 'phase_changed' | 'ingest_page' | 'remapped' | 'tombstoned' | 'completed' | 'error' */ + kind: string; + phase?: string | null; + ingestedTotal?: number | null; + batchCount?: number | null; + remappedCount?: number | null; + tombstonesChecked?: number | null; + tombstonesDeleted?: number | null; + completedKind?: string | null; + message?: string | null; + /** S1 per-batch timings from the Rust ingest runner (when available). */ + ingestMetrics?: IngestBatchMetrics | null; +} + +export interface IngestBatchMetrics { + offset: number; + strategy: string; + fetchMs: number; + writeMs: number; + lockWaitMs: number; + sqlExecMs: number; + persistMs: number; + rowCount: number; + bulkIngestActive: boolean; +} + +export interface LibrarySyncIdlePayload { + serverId: string; + libraryScope: string; + kind: string; // 'initial_sync' | 'delta_sync' + ok: boolean; + error?: string | null; +} + +export function subscribeLibrarySyncProgress( + handler: (payload: LibrarySyncProgressPayload) => void, +): Promise { + return listen('library:sync-progress', ({ payload }) => + handler(payload), + ); +} + +export function subscribeLibrarySyncIdle( + handler: (payload: LibrarySyncIdlePayload) => void, +): Promise { + return listen('library:sync-idle', ({ payload }) => + handler(payload), + ); +} diff --git a/src/api/subsonic.contract.test.ts b/src/api/subsonic.contract.test.ts index 2857839c..065d170c 100644 --- a/src/api/subsonic.contract.test.ts +++ b/src/api/subsonic.contract.test.ts @@ -20,7 +20,7 @@ import { } from './subsonicStreamUrl'; import { beforeEach, describe, expect, it } from 'vitest'; import { parseSubsonicEntityStarRating } from './subsonicRatings'; -import { getClient, libraryFilterParams } from './subsonicClient'; +import { getClient, libraryFilterParams, libraryScopeForServer } from './subsonicClient'; import { useAuthStore } from '@/store/authStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; @@ -78,6 +78,25 @@ describe('libraryFilterParams', () => { }); }); +describe('libraryScopeForServer', () => { + it('returns undefined for all or unset filters', () => { + const serverId = setUpServer(); + expect(libraryScopeForServer(serverId)).toBeUndefined(); + useAuthStore.setState({ + musicLibraryFilterByServer: { [serverId]: 'all' }, + }); + expect(libraryScopeForServer(serverId)).toBeUndefined(); + }); + + it('returns the folder id when scoped', () => { + const serverId = setUpServer(); + useAuthStore.setState({ + musicLibraryFilterByServer: { [serverId]: 'mf-7' }, + }); + expect(libraryScopeForServer(serverId)).toBe('mf-7'); + }); +}); + describe('getClient', () => { it('throws when no server is configured', () => { expect(() => getClient()).toThrow(/no server configured/i); diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index 69707ffd..cf27d9ae 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -68,12 +68,18 @@ export async function apiForServer( return apiWithCredentials(server.url, server.username, server.password, endpoint, extra, timeout); } -export async function api(endpoint: string, extra: Record = {}, timeout = 15000): Promise { +export async function api( + endpoint: string, + extra: Record = {}, + timeout = 15000, + signal?: AbortSignal, +): Promise { const { baseUrl, params } = getClient(); const resp = await axios.get(`${baseUrl}/${endpoint}`, { params: { ...params, ...extra }, paramsSerializer: { indexes: null }, timeout, + signal, }); const data = resp.data?.['subsonic-response']; if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)'); @@ -87,9 +93,16 @@ export function libraryFilterParams(): Record { return activeServerId ? libraryFilterParamsForServer(activeServerId) : {}; } +/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */ +export function libraryScopeForServer(serverId: string): string | undefined { + const f = useAuthStore.getState().musicLibraryFilterByServer[serverId]; + if (f === undefined || f === 'all') return undefined; + return f; +} + /** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */ export function libraryFilterParamsForServer(serverId: string): Record { - const f = useAuthStore.getState().musicLibraryFilterByServer[serverId]; - if (f === undefined || f === 'all') return {}; - return { musicFolderId: f }; + const scope = libraryScopeForServer(serverId); + if (!scope) return {}; + return { musicFolderId: scope }; } diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index 830bc2df..27077337 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -1,5 +1,6 @@ import { api, apiForServer } from './subsonicClient'; import type { SubsonicNowPlaying } from './subsonicTypes'; +import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; async function scrobbleOnServer( serverId: string, @@ -16,6 +17,10 @@ export async function scrobbleSong(id: string, time: number, serverId: string): if (!serverId) return; try { await scrobbleOnServer(serverId, id, true, time); + // Patch-on-use (§6.5 / F3): reflect the play in the local index so the + // "recently played" surfaces aren't stale. `play_count` is left to the next + // sync (the patch sets absolute values; a correct increment needs the base). + patchLibraryTrackOnUse(serverId, id, { playedAt: time }); } catch { // best effort } diff --git a/src/api/subsonicSearch.ts b/src/api/subsonicSearch.ts index 39ffe339..641e9cf7 100644 --- a/src/api/subsonicSearch.ts +++ b/src/api/subsonicSearch.ts @@ -15,7 +15,15 @@ export function filterSearchArtistsWithNoAlbums(artists: SubsonicArtist[]): Subs return artists.filter((a) => a.albumCount !== 0); } -export async function search(query: string, options?: { albumCount?: number; artistCount?: number; songCount?: number }): Promise { +export async function search( + query: string, + options?: { + albumCount?: number; + artistCount?: number; + songCount?: number; + signal?: AbortSignal; + }, +): Promise { if (!query.trim()) return { artists: [], albums: [], songs: [] }; const data = await api<{ searchResult3: { @@ -23,13 +31,18 @@ export async function search(query: string, options?: { albumCount?: number; art album?: SubsonicAlbum[]; song?: SubsonicSong[]; }; - }>('search3.view', { - query, - artistCount: options?.artistCount ?? 5, - albumCount: options?.albumCount ?? 5, - songCount: options?.songCount ?? 10, - ...libraryFilterParams(), - }); + }>( + 'search3.view', + { + query, + artistCount: options?.artistCount ?? 5, + albumCount: options?.albumCount ?? 5, + songCount: options?.songCount ?? 10, + ...libraryFilterParams(), + }, + 15000, + options?.signal, + ); const r = data.searchResult3 ?? {}; return { artists: filterSearchArtistsWithNoAlbums(r.artist ?? []), diff --git a/src/api/subsonicStarRating.ts b/src/api/subsonicStarRating.ts index 2ce82537..eb5aa39b 100644 --- a/src/api/subsonicStarRating.ts +++ b/src/api/subsonicStarRating.ts @@ -1,5 +1,7 @@ import { api, libraryFilterParams } from './subsonicClient'; import { invalidateEntityUserRatingCaches } from './subsonicRatings'; +import { useAuthStore } from '../store/authStore'; +import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; import type { EntityRatingSupportLevel, StarredResults, @@ -26,6 +28,9 @@ export async function star(id: string, type: 'song' | 'album' | 'artist' = 'albu if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; await api('star.view', params); + if (type === 'song') { + patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: Date.now() }); + } } export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise { @@ -34,10 +39,15 @@ export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'al if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; await api('unstar.view', params); + if (type === 'song') { + patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: null }); + } } export async function setRating(id: string, rating: number): Promise { await api('setRating.view', { id, rating }); + // No-op in Rust when `id` is an album/artist (no track row matches). + patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating }); // Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become // stale immediately. `invalidateEntityUserRatingCaches` is static-imported: // mix paths already pull `subsonicRatings` (e.g. mixRatingFilter), so a diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 57987d68..0f4da500 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -10,10 +10,13 @@ import ExportPickerModal from '../components/ExportPickerModal'; import ZipDownloadOverlay from '../components/ZipDownloadOverlay'; import FpsOverlay from '../components/FpsOverlay'; import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; +import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; +import { hydrateQueueFromIndex } from '../utils/library/queueRestore'; import { IS_WINDOWS } from '../utils/platform'; import TauriEventBridge from './TauriEventBridge'; import AppShell from './AppShell'; @@ -34,6 +37,21 @@ export default function MainApp() { // Advanced Mode toggle. Idempotent — flagged in localStorage. useEffect(() => { runAdvancedModeMigration(); }, []); + // Re-bind the library sync session whenever the active server changes + // (covers app startup + server switch). The session is Rust + // process-memory only while the per-server index toggle persists, so + // without this the background scheduler + Sync now report + // "no bound session" after a restart. + const activeServerId = useAuthStore(s => s.activeServerId); + const serverIdsKey = useAuthStore(s => s.servers.map(srv => srv.id).join(',')); + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + useEffect(() => { + void (async () => { + await bootstrapAllIndexedServers(); + void hydrateQueueFromIndex(); + })(); + }, [activeServerId, serverIdsKey, masterEnabled]); + // Push playback state to mini window + handle control events. useEffect(() => { return initMiniPlayerBridgeOnMain(); diff --git a/src/app/TauriEventBridge.tsx b/src/app/TauriEventBridge.tsx index ae6adb5a..65307c98 100644 --- a/src/app/TauriEventBridge.tsx +++ b/src/app/TauriEventBridge.tsx @@ -7,6 +7,7 @@ import { useTrayIconSync } from '../hooks/tauriBridge/useTrayIconSync'; import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings'; import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge'; import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher'; +import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog'; /** * Single mount point for everything that bridges Rust ↔ React in the main @@ -33,6 +34,7 @@ export function TauriEventBridge() { useInAppKeybindings(navigate); useMediaAndWindowBridge(navigate); usePlayerSnapshotPublisher(); + useLibraryDevSyncLog(); return null; } diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index d7b4299b..87ed97d8 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,4 +1,4 @@ -import { star, unstar } from '../api/subsonicStarRating'; +import { queueSongStar } from '../store/pendingStarSync'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import { playbackCoverArtForId } from '../utils/playback/playbackServer'; import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; @@ -34,7 +34,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const previous = usePlayerStore(s => s.previous); const stop = usePlayerStore(s => s.stop); const toggleRepeat = usePlayerStore(s => s.toggleRepeat); - const setStarredOverride = usePlayerStore(s => s.setStarredOverride); // Derive isStarred inside the selector so we only re-render when the boolean // actually flips — not when any unrelated track's star status changes. const isStarred = usePlayerStore(s => { @@ -43,17 +42,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred; }); - const toggleStar = useCallback(async () => { + const toggleStar = useCallback(() => { if (!currentTrack) return; - const nextVal = !isStarred; - setStarredOverride(currentTrack.id, nextVal); - try { - if (nextVal) await star(currentTrack.id, 'song'); - else await unstar(currentTrack.id, 'song'); - } catch { - setStarredOverride(currentTrack.id, !nextVal); - } - }, [currentTrack, isStarred, setStarredOverride]); + queueSongStar(currentTrack.id, !isStarred); + }, [currentTrack, isStarred]); const duration = currentTrack?.duration ?? 0; diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index b7f8710e..1a5c7f04 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -1,25 +1,33 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; -import { search } from '../api/subsonicSearch'; +import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library'; import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; +import { + LIVE_SEARCH_DEBOUNCE_NETWORK_MS, + LIVE_SEARCH_DEBOUNCE_RACE_MS, + EMPTY_SEARCH_RESULTS, + liveSearchQueryTooShort, + runLocalLiveSearch, + runNetworkLiveSearch, +} from '../utils/library/liveSearchLocal'; +import { raceSearchSources } from '../utils/library/searchRace'; +import { libraryIsReady } from '../utils/library/libraryReady'; +import { + logLibrarySearch, +} from '../utils/library/libraryDevLog'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react'; +import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useTranslation } from 'react-i18next'; import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage'; import { showToast } from '../utils/ui/toast'; import { useShareSearch } from '../hooks/useShareSearch'; import ShareSearchResults from './search/ShareSearchResults'; -function debounce(fn: (q: string) => void, ms: number): (q: string) => void { - let timer: ReturnType; - return (q: string) => { - clearTimeout(timer); - timer = setTimeout(() => fn(q), ms); - }; -} +type LiveSearchSource = 'local' | 'network'; function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) { const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); @@ -56,6 +64,9 @@ export default function LiveSearch() { const [activeIndex, setActiveIndex] = useState(-1); const [isFocused, setIsFocused] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); + const [searchSource, setSearchSource] = useState(null); + const localReadyRef = useRef(false); + const liveSearchGenRef = useRef(0); const navigate = useNavigate(); const enqueue = usePlayerStore(state => state.enqueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); @@ -67,41 +78,174 @@ export default function LiveSearch() { const inputRef = useRef(null); const collapsedRef = useRef(false); const compactHeaderControlsRef = useRef(false); + const serverId = useAuthStore(s => s.activeServerId); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + + const refreshLocalReady = useCallback(async () => { + if (!serverId || !indexEnabled) { + localReadyRef.current = false; + return; + } + localReadyRef.current = await libraryIsReady(serverId); + }, [serverId, indexEnabled]); + + useEffect(() => { + void refreshLocalReady(); + }, [refreshLocalReady, musicLibraryFilterVersion]); + + useEffect(() => { + if (!indexEnabled || !serverId) return; + let unlistenProgress: (() => void) | undefined; + let unlistenIdle: (() => void) | undefined; + void subscribeLibrarySyncIdle(payload => { + if (payload.serverId === serverId) void refreshLocalReady(); + }).then(fn => { + unlistenIdle = fn; + }); + void subscribeLibrarySyncProgress(p => { + if (p.serverId === serverId && p.kind === 'phase_changed') void refreshLocalReady(); + }).then(fn => { + unlistenProgress = fn; + }); + return () => { + unlistenIdle?.(); + unlistenProgress?.(); + }; + }, [indexEnabled, serverId, refreshLocalReady]); const closeSearch = useCallback(() => { setOpen(false); setQuery(''); + setSearchSource(null); }, []); const share = useShareSearch(query, closeSearch); - const doSearch = useCallback( - debounce(async (q: string) => { - if (!q.trim()) { setResults(null); setOpen(false); return; } - setLoading(true); - try { - const r = await search(q); - setResults(r); - setOpen(true); - } finally { - setLoading(false); - } - }, 300), - [musicLibraryFilterVersion] - ); - useEffect(() => { if (share.shareMatch) { setResults(null); setLoading(false); + setSearchSource(null); setOpen(true); setActiveIndex(-1); return; } - doSearch(query); + + const q = query.trim(); + if (!q) { + setResults(null); + setOpen(false); + setSearchSource(null); + setLoading(false); + return; + } + + setSearchSource(null); setActiveIndex(-1); - }, [query, doSearch, share.shareMatch]); + + const abort = new AbortController(); + const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS; + + const timer = window.setTimeout(() => { + void (async () => { + const gen = liveSearchGenRef.current; + const isStale = () => + gen !== liveSearchGenRef.current || abort.signal.aborted; + + if (isStale()) return; + + setLoading(true); + const searchT0 = performance.now(); + try { + if (liveSearchQueryTooShort(q)) { + if (!isStale()) { + setResults(EMPTY_SEARCH_RESULTS); + setSearchSource('local'); + setOpen(true); + } + return; + } + + const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId }; + + if (indexEnabled && serverId) { + const winner = await raceSearchSources( + [ + { + source: 'local', + run: () => runLocalLiveSearch(serverId, q, raceCtx), + }, + { + source: 'network', + run: () => runNetworkLiveSearch(q, abort.signal), + }, + ], + isStale, + ); + if (isStale()) return; + if (winner) { + setResults(winner.result); + setSearchSource(winner.source); + setOpen(true); + logLibrarySearch({ + at: new Date().toISOString(), + query: q, + path: 'search_race', + durationMs: Math.round(performance.now() - searchT0), + debounceMs, + indexEnabled, + localReadyCached: localReadyRef.current, + raceWinner: winner.source, + raceWinnerMs: winner.durationMs, + counts: { + artists: winner.result.artists.length, + albums: winner.result.albums.length, + songs: winner.result.songs.length, + }, + }); + return; + } + showToast(t('search.liveSearchFailed'), 3200, 'error'); + } else if (serverId) { + const network = await runNetworkLiveSearch(q, abort.signal); + if (isStale()) return; + if (network) { + setResults(network); + setSearchSource('network'); + setOpen(true); + logLibrarySearch({ + at: new Date().toISOString(), + query: q, + path: 'search3', + durationMs: Math.round(performance.now() - searchT0), + debounceMs, + indexEnabled, + counts: { + artists: network.artists.length, + albums: network.albums.length, + songs: network.songs.length, + }, + }); + } + } + } catch (err) { + if (isStale()) return; + const name = err instanceof Error ? err.name : ''; + if (name === 'CanceledError' || name === 'AbortError') return; + showToast(t('search.liveSearchFailed'), 3200, 'error'); + } finally { + if (!isStale()) setLoading(false); + } + })(); + }, debounceMs); + + return () => { + window.clearTimeout(timer); + abort.abort(); + liveSearchGenRef.current += 1; + }; + }, [query, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]); const isSearchActive = isFocused || open || query.trim().length > 0; @@ -332,7 +476,16 @@ export default function LiveSearch() { autoComplete="off" /> {query && ( - )} @@ -355,6 +508,31 @@ export default function LiveSearch() { {open && (
+ {searchSource && !share.shareMatch && ( +
+ {searchSource === 'local' ? ( + + ) : ( + + )} + + {t( + searchSource === 'local' + ? 'search.localIndexBadge' + : 'search.networkSearchBadge', + )} + +
+ )} + {!hasResults && !loading && (
{t('search.noResults', { query })}
)} @@ -465,7 +643,11 @@ export default function LiveSearch() { openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song'); }} role="option" aria-selected={activeIndex === i}> -
+ {(s.coverArt ?? s.albumId) ? ( + + ) : ( +
+ )}
{s.title}
{s.artist} · {s.album}
diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index db176bc4..c0aa63ac 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -1,4 +1,4 @@ -import { star, unstar } from '../api/subsonicStarRating'; +import { queueSongStar } from '../store/pendingStarSync'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { Track } from '../store/playerStoreTypes'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; @@ -182,7 +182,6 @@ export default function MobilePlayerView() { const toggleRepeat = usePlayerStore(s => s.toggleRepeat); const shuffleQueue = usePlayerStore(s => s.shuffleQueue); const starredOverrides = usePlayerStore(s => s.starredOverrides); - const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const duration = currentTrack?.duration ?? 0; @@ -197,17 +196,10 @@ export default function MobilePlayerView() { ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; - const toggleStar = useCallback(async () => { + const toggleStar = useCallback(() => { if (!currentTrack) return; - const nextVal = !isStarred; - setStarredOverride(currentTrack.id, nextVal); - try { - if (nextVal) await star(currentTrack.id, 'song'); - else await unstar(currentTrack.id, 'song'); - } catch { - setStarredOverride(currentTrack.id, !nextVal); - } - }, [currentTrack, isStarred, setStarredOverride]); + queueSongStar(currentTrack.id, !isStarred); + }, [currentTrack, isStarred]); // Scrubber touch/mouse drag const scrubberRef = useRef(null); diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 3ec162c2..2887bd46 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -1,4 +1,4 @@ -import { star, unstar } from '../api/subsonicStarRating'; +import { queueSongStar } from '../store/pendingStarSync'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { SubsonicAlbum } from '../api/subsonicTypes'; @@ -62,8 +62,8 @@ export default function PlayerBar() { stop, toggleRepeat, repeatMode, toggleFullscreen, lastfmLoved, toggleLastfmLove, isQueueVisible, toggleQueue, - starredOverrides, setStarredOverride, - userRatingOverrides, setUserRatingOverride, + starredOverrides, + userRatingOverrides, openContextMenu, } = usePlayerStore(useShallow(s => ({ currentTrack: s.currentTrack, @@ -83,9 +83,7 @@ export default function PlayerBar() { isQueueVisible: s.isQueueVisible, toggleQueue: s.toggleQueue, starredOverrides: s.starredOverrides, - setStarredOverride: s.setStarredOverride, userRatingOverrides: s.userRatingOverrides, - setUserRatingOverride: s.setUserRatingOverride, openContextMenu: s.openContextMenu, }))); const { lastfmSessionKey } = useAuthStore(); @@ -133,17 +131,10 @@ export default function PlayerBar() { ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; - const toggleStar = useCallback(async () => { + const toggleStar = useCallback(() => { if (!currentTrack) return; - const next = !isStarred; - setStarredOverride(currentTrack.id, next); - try { - if (next) await star(currentTrack.id, 'song'); - else await unstar(currentTrack.id, 'song'); - } catch { - setStarredOverride(currentTrack.id, !next); - } - }, [currentTrack, isStarred, setStarredOverride]); + queueSongStar(currentTrack.id, !isStarred); + }, [currentTrack, isStarred]); const duration = currentTrack?.duration ?? 0; @@ -232,7 +223,6 @@ export default function PlayerBar() { lastfmLoved={lastfmLoved} toggleLastfmLove={toggleLastfmLove} userRatingOverrides={userRatingOverrides} - setUserRatingOverride={setUserRatingOverride} toggleFullscreen={toggleFullscreen} navigate={navigatePlaybackLibrary} openContextMenu={openContextMenu} diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx index 2f835679..971d4b9b 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -7,6 +7,8 @@ import { Search as SearchIcon, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ndListSongs } from '../api/navidromeBrowse'; +import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal'; +import { useAuthStore } from '../store/authStore'; import SongRow, { SongListHeader } from './SongRow'; const PAGE_SIZE = 50; @@ -15,14 +17,21 @@ const ROW_HEIGHT = 52; const PREFETCH_PX = 600; /** - * Empty query → Navidrome /api/song sorted by title (no Subsonic equivalent). + * Browse-all (empty query): local library index when ready (F1, same title-ASC + * order), else Navidrome /api/song sorted by title, else Subsonic search3. * Non-empty → Subsonic search3 (search isn't a browse). - * Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3. + * Either way, returns a SubsonicSong[]. */ async function fetchSongPage(query: string, offset: number): Promise { if (query !== '') { return searchSongsPaged(query, PAGE_SIZE, offset); } + const local = await runLocalSongBrowse( + useAuthStore.getState().activeServerId, + offset, + PAGE_SIZE, + ); + if (local) return local; try { return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC'); } catch { diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 4706fed5..4d91ccdd 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react'; -import { star, unstar } from '../../api/subsonicStarRating'; +import { queueSongStar } from '../../store/pendingStarSync'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import type { Track } from '../../store/playerStoreTypes'; import { useAuthStore } from '../../store/authStore'; @@ -13,7 +13,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, @@ -71,9 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - const starred = isStarred(song.id, song.starred); - setStarredOverride(song.id, !starred); - return starred ? unstar(song.id, 'song') : star(song.id, 'song'); + queueSongStar(song.id, !isStarred(song.id, song.starred)); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index cf5feb63..ec28d32b 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { getAlbum } from '../../api/subsonicLibrary'; -import { star, unstar } from '../../api/subsonicStarRating'; +import { queueSongStar } from '../../store/pendingStarSync'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import type { Track } from '../../store/playerStoreTypes'; import { useAuthStore } from '../../store/authStore'; @@ -19,7 +19,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, @@ -119,9 +119,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - const starred = isStarred(song.id, song.starred); - setStarredOverride(song.id, !starred); - return starred ? unstar(song.id, 'song') : star(song.id, 'song'); + queueSongStar(song.id, !isStarred(song.id, song.starred)); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} @@ -303,8 +301,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => { - setStarredOverride(song.id, false); - return unstar(song.id, 'song'); + queueSongStar(song.id, false); })}> {t('contextMenu.unfavorite')}
diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx index 7b94e4c0..0ded9168 100644 --- a/src/components/playerBar/PlayerTrackInfo.tsx +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -1,6 +1,6 @@ import { Cast, Heart, Maximize2, Music } from 'lucide-react'; import type { TFunction } from 'i18next'; -import { setRating } from '../../api/subsonicStarRating'; +import { queueSongRating } from '../../store/pendingStarSync'; import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from '../../api/subsonicTypes'; import type { PlayerState, Track } from '../../store/playerStoreTypes'; import type { RadioMetadata } from '../../hooks/useRadioMetadata'; @@ -39,7 +39,6 @@ interface Props { lastfmLoved: boolean; toggleLastfmLove: () => void; userRatingOverrides: Record; - setUserRatingOverride: (id: string, r: number) => void; toggleFullscreen: () => void; navigate: (to: string) => void | Promise; openContextMenu: PlayerState['openContextMenu']; @@ -51,7 +50,7 @@ export function PlayerTrackInfo({ coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs, showPreviewMeta, previewingTrack, isStarred, toggleStar, lastfmSessionKey, lastfmLoved, toggleLastfmLove, - userRatingOverrides, setUserRatingOverride, toggleFullscreen, + userRatingOverrides, toggleFullscreen, navigate, openContextMenu, t, }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); @@ -159,7 +158,7 @@ export function PlayerTrackInfo({ {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && ( { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} + onChange={r => queueSongRating(currentTrack.id, r)} className="player-track-rating" ariaLabel={t('albumDetail.ratingLabel')} /> diff --git a/src/components/settings/LibraryIndexSection.tsx b/src/components/settings/LibraryIndexSection.tsx new file mode 100644 index 00000000..b3d578bb --- /dev/null +++ b/src/components/settings/LibraryIndexSection.tsx @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DatabaseZap } from 'lucide-react'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import { showToast } from '../../utils/ui/toast'; +import SettingsSubSection from '../SettingsSubSection'; +import { + libraryGetStatus, + librarySyncCancel, + librarySyncClearSession, + subscribeLibrarySyncIdle, + subscribeLibrarySyncProgress, + type SyncStateDto, +} from '../../api/library'; +import { + bootstrapAllIndexedServers, + bootstrapIndexedServer, + type BindServerResult, +} from '../../utils/library/librarySession'; +import { enqueueLibrarySync } from '../../utils/library/librarySyncQueue'; +import { syncIngestDisplayCount } from '../../utils/library/libraryReady'; +import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import LibraryIndexServerRow, { type LibraryServerConnection } from './LibraryIndexServerRow'; + +const STATUS_POLL_MS = 3000; +const SYNC_POLL_MS = 2500; +const OFFLINE_RETRY_MS = 60_000; + +export default function LibraryIndexSection() { + const { t } = useTranslation(); + const servers = useAuthStore(s => s.servers); + const activeServerId = useAuthStore(s => s.activeServerId); + + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer); + const setMasterEnabled = useLibraryIndexStore(s => s.setMasterEnabled); + const setServerSyncExcluded = useLibraryIndexStore(s => s.setServerSyncExcluded); + const autoReconcile = useLibraryIndexStore(s => s.autoReconcileEnabled); + const setAutoReconcile = useLibraryIndexStore(s => s.setAutoReconcileEnabled); + + const indexedIds = useMemo(() => { + if (!masterEnabled) return []; + return servers.map(s => s.id).filter(id => syncExcludedByServer[id] !== true); + }, [masterEnabled, syncExcludedByServer, servers]); + + const indexedServers = useMemo( + () => servers.filter(s => indexedIds.includes(s.id)), + [servers, indexedIds], + ); + + const excludedServers = useMemo( + () => servers.filter(s => syncExcludedByServer[s.id] === true), + [servers, syncExcludedByServer], + ); + + const [statusByServer, setStatusByServer] = useState>({}); + const [connectionByServer, setConnectionByServer] = useState>({}); + const [progressByServer, setProgressByServer] = useState>({}); + const [busyServerId, setBusyServerId] = useState(null); + const [bootstrapping, setBootstrapping] = useState(false); + + const pollTimer = useRef | null>(null); + const ingestCountRef = useRef>({}); + const syncPhaseRef = useRef>({}); + + const applyConnectionResults = useCallback((results: Record) => { + setConnectionByServer(prev => { + const next = { ...prev }; + for (const [id, result] of Object.entries(results)) { + next[id] = result === 'offline' ? 'offline' : result === 'bound' ? 'online' : 'unknown'; + } + return next; + }); + }, []); + + const refreshAllStatuses = useCallback(async () => { + if (!masterEnabled || indexedServers.length === 0) return; + const entries = await Promise.all( + indexedServers.map(async srv => { + try { + const fresh = await libraryGetStatus(srv.id); + syncPhaseRef.current[srv.id] = fresh.syncPhase; + if (fresh.syncPhase === 'initial_sync') { + const next = Math.max(ingestCountRef.current[srv.id] ?? 0, syncIngestDisplayCount(fresh)); + ingestCountRef.current[srv.id] = next; + setProgressByServer(p => ({ + ...p, + [srv.id]: t('settings.libraryIndexProgressIngest', { count: next }), + })); + } else if (fresh.syncPhase === 'ready' || fresh.syncPhase === 'idle') { + ingestCountRef.current[srv.id] = 0; + } + return [srv.id, fresh] as const; + } catch { + return [srv.id, null] as const; + } + }), + ); + setStatusByServer(Object.fromEntries(entries)); + }, [masterEnabled, indexedServers, t]); + + const runBootstrap = useCallback(async () => { + if (!masterEnabled) return; + setBootstrapping(true); + try { + const results = await bootstrapAllIndexedServers(); + applyConnectionResults(results); + await refreshAllStatuses(); + } finally { + setBootstrapping(false); + } + }, [masterEnabled, applyConnectionResults, refreshAllStatuses]); + + const retryOfflineServers = useCallback(async () => { + if (!masterEnabled) return; + const offline = indexedServers.filter(s => connectionByServer[s.id] === 'offline'); + if (offline.length === 0) return; + const results: Record = {}; + for (const srv of offline) { + results[srv.id] = await bootstrapIndexedServer(srv); + } + applyConnectionResults(results); + void refreshAllStatuses(); + }, [masterEnabled, indexedServers, connectionByServer, applyConnectionResults, refreshAllStatuses]); + + useEffect(() => { + if (!masterEnabled) { + setStatusByServer({}); + setConnectionByServer({}); + setProgressByServer({}); + setBusyServerId(null); + return; + } + void runBootstrap(); + }, [masterEnabled, indexedIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!masterEnabled) return; + const poll = () => { + void refreshAllStatuses(); + const anyInitial = indexedServers.some( + s => syncPhaseRef.current[s.id] === 'initial_sync', + ); + pollTimer.current = setTimeout(poll, anyInitial ? SYNC_POLL_MS : STATUS_POLL_MS); + }; + poll(); + return () => { + if (pollTimer.current) clearTimeout(pollTimer.current); + pollTimer.current = null; + }; + }, [masterEnabled, indexedServers, refreshAllStatuses]); + + useEffect(() => { + if (!masterEnabled) return; + const retryTimer = setInterval(() => { + void retryOfflineServers(); + }, OFFLINE_RETRY_MS); + return () => clearInterval(retryTimer); + }, [masterEnabled, retryOfflineServers]); + + useEffect(() => { + if (!masterEnabled) return; + const unsubs: Array void>> = [ + subscribeLibrarySyncProgress(p => { + if (!indexedIds.includes(p.serverId)) return; + setBusyServerId(p.serverId); + if (p.kind === 'ingest_page') { + const next = Math.max(ingestCountRef.current[p.serverId] ?? 0, p.ingestedTotal ?? 0); + ingestCountRef.current[p.serverId] = next; + setProgressByServer(prev => ({ + ...prev, + [p.serverId]: t('settings.libraryIndexProgressIngest', { count: next }), + })); + } else if (p.kind === 'tombstoned') { + setProgressByServer(prev => ({ + ...prev, + [p.serverId]: t('settings.libraryIndexProgressVerify', { + checked: p.tombstonesChecked ?? 0, + deleted: p.tombstonesDeleted ?? 0, + }), + })); + } else if (p.kind === 'phase_changed' && p.phase) { + setProgressByServer(prev => ({ ...prev, [p.serverId]: p.phase ?? null })); + } + }), + subscribeLibrarySyncIdle(p => { + if (!indexedIds.includes(p.serverId)) return; + setBusyServerId(cur => (cur === p.serverId ? null : cur)); + ingestCountRef.current[p.serverId] = 0; + setProgressByServer(prev => ({ ...prev, [p.serverId]: null })); + void refreshAllStatuses(); + if (!p.ok && p.error) { + showToast(t('settings.libraryIndexSyncError', { error: p.error }), 5000, 'error'); + } + }), + ]; + return () => { + unsubs.forEach(u => void u.then(fn => fn())); + }; + }, [masterEnabled, indexedIds, refreshAllStatuses, t]); + + const handleMasterToggle = async (enabled: boolean) => { + if (enabled) { + setMasterEnabled(true); + await runBootstrap(); + return; + } + setBootstrapping(true); + try { + for (const srv of servers) { + try { + await librarySyncClearSession(srv.id); + } catch { + /* best-effort */ + } + } + setMasterEnabled(false); + setStatusByServer({}); + setConnectionByServer({}); + setProgressByServer({}); + setBusyServerId(null); + } finally { + setBootstrapping(false); + } + }; + + const runServerAction = async ( + serverId: string, + action: 'full' | 'delta' | 'verify', + ) => { + setBusyServerId(serverId); + try { + const kind = + action === 'verify' + ? 'verify' + : action === 'full' + ? 'full' + : statusByServer[serverId]?.lastFullSyncAt + ? 'delta' + : 'full'; + ingestCountRef.current[serverId] = 0; + await enqueueLibrarySync({ serverId, kind }); + } catch (e) { + setBusyServerId(null); + showToast(t('settings.libraryIndexSyncError', { error: String(e) }), 5000, 'error'); + } + }; + + const handleIncludeServer = async (serverId: string) => { + setServerSyncExcluded(serverId, false); + const srv = servers.find(s => s.id === serverId); + if (srv) { + setBootstrapping(true); + try { + const result = await bootstrapIndexedServer(srv); + applyConnectionResults({ [serverId]: result }); + await refreshAllStatuses(); + } finally { + setBootstrapping(false); + } + } + }; + + const handleExcludeServer = async (serverId: string) => { + setBootstrapping(true); + try { + await librarySyncClearSession(serverId); + setServerSyncExcluded(serverId, true); + setStatusByServer(prev => { + const next = { ...prev }; + delete next[serverId]; + return next; + }); + setConnectionByServer(prev => { + const next = { ...prev }; + delete next[serverId]; + return next; + }); + } catch (e) { + showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error'); + } finally { + setBootstrapping(false); + } + }; + + const handleCancel = async () => { + try { + await librarySyncCancel(); + } catch { + /* best-effort */ + } + }; + + const globalBusy = bootstrapping || busyServerId != null; + + return ( + } + > +
+

+ {t('settings.libraryIndexDesc')} +

+

+ {t('settings.libraryIndexDeltaHint')} +

+ +
+
+
{t('settings.libraryIndexEnable')}
+
+ {servers.length > 0 + ? t('settings.libraryIndexEnableAllDesc') + : t('settings.libraryIndexNoServer')} +
+
+ +
+ + {masterEnabled && ( + <> +
+
+ {t('settings.libraryIndexServerListTitle')} +
+ {indexedServers.length === 0 ? ( +

+ {t('settings.libraryIndexAllExcluded')} +

+ ) : ( +
+ {indexedServers.map(srv => ( + void runServerAction(srv.id, 'full')} + onDeltaSync={() => void runServerAction(srv.id, 'delta')} + onVerify={() => void runServerAction(srv.id, 'verify')} + onExclude={() => void handleExcludeServer(srv.id)} + /> + ))} +
+ )} + + {excludedServers.length > 0 && ( + <> +
+ {t('settings.libraryIndexExcludedTitle')} +
+
+ {excludedServers.map(srv => ( +
+ {serverListDisplayLabel(srv, servers)} + +
+ ))} +
+ + )} + + {busyServerId && ( +
+ +
+ )} + +
+
+
+
{t('settings.libraryIndexAutoReconcile')}
+
+ {t('settings.libraryIndexAutoReconcileDesc')} +
+
+ +
+ + )} +
+ + ); +} diff --git a/src/components/settings/LibraryIndexServerRow.tsx b/src/components/settings/LibraryIndexServerRow.tsx new file mode 100644 index 00000000..4156b9a5 --- /dev/null +++ b/src/components/settings/LibraryIndexServerRow.tsx @@ -0,0 +1,145 @@ +import { RefreshCw, ShieldCheck, WifiOff, Zap, Ban } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import type { SyncStateDto } from '../../api/library'; +import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import { + libraryStatusDisplayTrackCount, + libraryStatusIsReady, +} from '../../utils/library/libraryReady'; + +export type LibraryServerConnection = 'online' | 'offline' | 'unknown'; + +interface LibraryIndexServerRowProps { + server: ServerProfile; + allServers: ServerProfile[]; + isActive: boolean; + status: SyncStateDto | null; + connection: LibraryServerConnection; + progressLabel: string | null; + busy: boolean; + actionsDisabled: boolean; + onFullSync: () => void; + onDeltaSync: () => void; + onVerify: () => void; + onExclude: () => void; +} + +export default function LibraryIndexServerRow({ + server, + allServers, + isActive, + status, + connection, + progressLabel, + busy, + actionsDisabled, + onFullSync, + onDeltaSync, + onVerify, + onExclude, +}: LibraryIndexServerRowProps) { + const { t } = useTranslation(); + const name = serverListDisplayLabel(server, allServers); + + const phaseLabel = (() => { + if (connection === 'offline') { + return t('settings.libraryIndexServerOffline'); + } + if (progressLabel) return progressLabel; + if (!status) return t('settings.libraryIndexStatusIdle'); + if (libraryStatusIsReady(status)) { + return t('settings.libraryIndexStatusReady', { + count: libraryStatusDisplayTrackCount(status), + }); + } + switch (status.syncPhase) { + case 'initial_sync': + return t('settings.libraryIndexStatusInitial'); + case 'error': + return t('settings.libraryIndexStatusError'); + case 'probing': + return t('settings.libraryIndexStatusProbing'); + default: + return t('settings.libraryIndexStatusIdle'); + } + })(); + + return ( +
+
+
+
+ {name} + {isActive && ( + + {t('settings.serverActive')} + + )} + {connection === 'offline' && ( + + + {t('settings.libraryIndexServerDeferred')} + + )} + {busy && ( + {t('settings.libraryIndexServerSyncing')} + )} +
+
+ {phaseLabel} +
+
+
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/settings/LibraryTab.tsx b/src/components/settings/LibraryTab.tsx index d5cb2100..5e9a7bb2 100644 --- a/src/components/settings/LibraryTab.tsx +++ b/src/components/settings/LibraryTab.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore'; import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults'; import SettingsSubSection from '../SettingsSubSection'; import StarRating from '../StarRating'; +import LibraryIndexSection from './LibraryIndexSection'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; @@ -15,6 +16,9 @@ export function LibraryTab() { return ( <> + {/* Local library index (spec §7.3) */} + + {/* Random Mix Blacklist */} { - if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { - auth.removeServer(server.id); + const deleteServer = async (server: ServerProfile) => { + if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { + return; + } + // §5.6: when a local library index exists for this server, let the + // user keep the cached rows (offline use) or delete them. OK = + // delete the cache, Cancel = keep it. + const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id); + const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary')); + + auth.removeServer(server.id); + useLibraryIndexStore.getState().setIndexEnabled(server.id, false); + try { + await librarySyncClearSession(server.id); + if (purgeLibrary) { + await libraryDeleteServerData(server.id); + } + } catch { + /* best-effort — server already removed from the store */ } }; @@ -161,6 +180,10 @@ export function ServersTab({ auth.setSubsonicServerIdentity(id, identity); scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); setConnStatus(s => ({ ...s, [id]: 'ok' })); + if (useLibraryIndexStore.getState().masterEnabled) { + const added = useAuthStore.getState().servers.find(s => s.id === id); + if (added) void bootstrapIndexedServer(added); + } } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } @@ -317,7 +340,7 @@ export function ServersTab({