diff --git a/CHANGELOG.md b/CHANGELOG.md index 19834f45..88ee3dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +### Servers — edit existing profiles + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)** + +* Pencil button opens an inline edit form prefilled with the existing profile. Card actions collapse to icon-only on narrow viewports so Edit/Delete stay reachable. + + + +### Local library index + search (preview) + +**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#846](https://github.com/Psychotoxical/psysonic/pull/846)** + +* **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. + + + ### Player stats — local listening history **By [@cucadmuh](https://github.com/cucadmuh), PR [#849](https://github.com/Psychotoxical/psysonic/pull/849)** @@ -51,22 +70,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Servers — edit existing profiles +### Analytics strategy + migration safety for index-key rebuild -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)** +**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)** -* Pencil button opens an inline edit form prefilled with the existing profile. Card actions collapse to icon-only on narrow viewports so Edit/Delete stay reachable. +* Rebuilt server scoping around stable `indexKey` identifiers across Rust + frontend paths used by playback, analysis, and local index state. +* Added per-server analysis strategy controls (lazy/aggressive), per-server parallelism tuning, queue progress visibility, and clear-analysis actions in **Settings → Library**. +* Added first-launch migration orchestration (inspect/run + progress events + blocking gate) with frontend persisted-key rewrites to the new `indexKey` scope. +* Reworked playback/analysis handoff paths (play, preload, stream/ranged, queue restore) so analysis dispatch and queue-priority hints use the same server scope model. +* Hardened startup/runtime migration checks so bootstrap waits for required migration phases before normal playback/index startup. -### Local library index + search (preview) +### Backup & Restore — library databases + full archive flow -**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#846](https://github.com/Psychotoxical/psysonic/pull/846)** +**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)** -* **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. +* **Settings → System → Backup & Restore:** added two archive-backed modes — **Library databases** (library + analysis SQLite snapshots) and **Full** (settings + library databases). +* Import auto-detects backup type from file contents (`.psybkp` / `.psylib` / `.psyfull`) from one entry point instead of per-mode import actions. +* Restore switches active databases via runtime store swap/restore flow and keeps previous files as `.bak` for recovery on failed validation. @@ -91,16 +113,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Library browse — local index race and catalog paths - -**By [@cucadmuh](https://github.com/cucadmuh), PR [#847](https://github.com/Psychotoxical/psysonic/pull/847)** - -* **Artists**, **Composers**, **Tracks**, and **Search Results** text search races local FTS against network search3; a ready index still serves hits when remote is down. -* **All Albums** paginated browse and genre filter, plus **Artists** catalog browse-all, read from the local index when ready (network fallback unchanged). -* DevTools: `[psysonic][library] browse-race …` lines for race winner, timings, hit counts, and fallback reason. - - - ### 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)** @@ -128,6 +140,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Library browse — local index race and catalog paths + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#847](https://github.com/Psychotoxical/psysonic/pull/847)** + +* **Artists**, **Composers**, **Tracks**, and **Search Results** text search races local FTS against network search3; a ready index still serves hits when remote is down. +* **All Albums** paginated browse and genre filter, plus **Artists** catalog browse-all, read from the local index when ready (network fallback unchanged). +* DevTools: `[psysonic][library] browse-race …` lines for race winner, timings, hit counts, and fallback reason. + + + ### Lyrics — sources can be turned off entirely **By [@Psychotoxical](https://github.com/Psychotoxical), suggested by sddania, PR [#855](https://github.com/Psychotoxical/psysonic/pull/855)** @@ -160,6 +182,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Backup UX — blocking progress gate for long operations + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)** + +* Backup/export and restore operations now show a global blocking status modal after file selection, so the app no longer looks frozen while archive and SQLite work runs. + + + ## Fixed ### In-page browse — virtual scroll and cover-art priority @@ -226,14 +256,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -### Local library index — full resync removes server-deleted tracks - -**By [@cucadmuh](https://github.com/cucadmuh), PR [#861](https://github.com/Psychotoxical/psysonic/pull/861)** - -* **Settings → Library → Full resync** now soft-deletes local rows that no longer exist on the server after a successful re-sync (mark-and-sweep via `resync_gen`), so **Ready (N tracks)** no longer stays inflated when tracks were removed on Navidrome/Subsonic. Delta tombstone reconcile is unchanged. - - - ### Playlists & Favorites — column picker on short lists **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#853](https://github.com/Psychotoxical/psysonic/pull/853)** @@ -250,6 +272,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Local library index — full resync removes server-deleted tracks + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#861](https://github.com/Psychotoxical/psysonic/pull/861)** + +* **Settings → Library → Full resync** now soft-deletes local rows that no longer exist on the server after a successful re-sync (mark-and-sweep via `resync_gen`), so **Ready (N tracks)** no longer stays inflated when tracks were removed on Navidrome/Subsonic. Delta tombstone reconcile is unchanged. + + + +### Server index-key migration — unknown/legacy data handling + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)** + +* Legacy destructive migration paths were replaced with a dual-DB import/switch flow that keeps old DBs as source until verification passes. +* Rows belonging to removed servers are explicitly skipped/purged from the active migrated DB scope instead of being silently carried forward. +* Legacy sqlite artifacts from old paths are now cleaned up after successful path migration (including WAL/SHM sidecars) to prevent stale old-version leftovers. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ed1554ea..c800f5f8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -417,6 +428,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "biquad" version = "0.6.0" @@ -547,6 +564,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -677,6 +714,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -753,6 +800,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "cookie" version = "0.18.1" @@ -1127,6 +1180,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2110,6 +2164,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2430,6 +2493,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3596,6 +3668,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3608,6 +3691,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3956,6 +4051,7 @@ dependencies = [ "webkit2gtk-nvidia-quirk", "windows 0.62.2", "zbus 5.15.0", + "zip 0.6.6", ] [[package]] @@ -5838,7 +5934,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -7920,6 +8016,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + [[package]] name = "zip" version = "4.6.1" @@ -7938,6 +8054,35 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "3.15.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1977fbc1..2ae4d7c5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,6 +73,7 @@ symphonia-adapter-libopus = "0.2.9" rusqlite = { version = "0.39", features = ["bundled"] } ebur128 = "0.1" dasp_sample = "0.11.0" +zip = "0.6.6" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml index 39e3b2be..f446d4eb 100644 --- a/src-tauri/crates/psysonic-analysis/Cargo.toml +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -19,3 +19,6 @@ md5 = "0.8" rusqlite = { version = "0.39", features = ["bundled"] } symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] } oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] } + +[dev-dependencies] +tauri = { version = "2", features = ["test"] } 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 119ae0fe..335e6c71 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs @@ -10,7 +10,9 @@ use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use symphonia::core::units::Time; -use tauri::Manager; +use tauri::{Manager, Runtime}; + +use crate::analysis_perf::AnalysisSeedTimings; use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry}; @@ -39,27 +41,31 @@ pub enum SeedFromBytesOutcome { /// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the /// 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, +pub fn seed_from_bytes_execute( + app: &tauri::AppHandle, server_id: &str, track_id: &str, bytes: &[u8], -) -> Result { +) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> { + let seed_started = Instant::now(); let Some(cache) = app.try_state::() else { crate::app_deprintln!( "[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}", track_id, bytes.len() ); - return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache); + return Ok(( + SeedFromBytesOutcome::SkippedNoAnalysisCache, + AnalysisSeedTimings::default(), + )); }; let (outcome, md5_16kb) = seed_from_bytes_into_cache(&cache, server_id, track_id, bytes)?; + let seed_ms = seed_started.elapsed().as_millis() as u64; // 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). + // has no row for this (server_id, track_id). Skipped when no server is known. if !server_id.is_empty() && matches!( outcome, @@ -70,15 +76,22 @@ pub fn seed_from_bytes_execute( sink.record_content_hash(server_id, track_id, &md5_16kb); } } - if !server_id.is_empty() { + let bpm_ms = if !server_id.is_empty() { + let bpm_started = Instant::now(); let _ = crate::track_enrichment::run_track_enrichment_if_needed( app, server_id, track_id, bytes, ); - } - Ok(outcome) + bpm_started.elapsed().as_millis() as u64 + } else { + 0 + }; + Ok(( + outcome, + AnalysisSeedTimings { seed_ms, bpm_ms }, + )) } /// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache @@ -95,9 +108,7 @@ pub fn seed_from_bytes_into_cache( bytes: &[u8], ) -> 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. + // Write under the playback server's scope. let key = TrackKey { server_id: server_id.to_string(), track_id: track_id.to_string(), @@ -167,6 +178,7 @@ pub fn seed_from_bytes_into_cache( } cache.touch_track_status(&key, "ready")?; + let _ = cache.checkpoint_wal("analysis.seed"); Ok((used_pcm_decode, bins_len)) })(); @@ -950,14 +962,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, md5) = seed_from_bytes_into_cache(&cache, "", "wav-track", &wav).unwrap(); + let (outcome, md5) = seed_from_bytes_into_cache(&cache, "server-a", "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(), + server_id: "server-a".to_string(), track_id: "wav-track".to_string(), md5_16kb: md5_first_16kb(&wav), }; @@ -979,25 +991,22 @@ mod tests { track_id: "scoped-track".to_string(), md5_16kb: md5.clone(), }; - let legacy = TrackKey { - server_id: String::new(), + assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope"); + let other = TrackKey { + server_id: "server-y".to_string(), 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" - ); + assert!(cache.get_waveform(&other).unwrap().is_none(), "row stays under the exact server"); } #[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, "server-a", "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, "server-a", "wav-track-2", &wav).unwrap(); assert_eq!( second, SeedFromBytesOutcome::SkippedWaveformCacheHit, @@ -1011,11 +1020,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, "server-a", "garbage", &bytes).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); let key = TrackKey { - server_id: String::new(), + server_id: "server-a".to_string(), track_id: "garbage".to_string(), md5_16kb: md5_first_16kb(&bytes), }; @@ -1026,4 +1035,204 @@ mod tests { "byte-envelope fallback must not cache loudness" ); } + + #[test] + fn audio_duration_from_bytes_reports_duration_for_wav() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100); + let duration = audio_duration_from_bytes(&wav).expect("duration must be available"); + assert!( + (1.8..=2.2).contains(&duration), + "expected ~2s duration, got {duration}" + ); + } + + #[test] + fn audio_duration_from_bytes_returns_none_for_garbage() { + assert!(audio_duration_from_bytes(b"not audio").is_none()); + } + + #[test] + fn decode_mono_pcm_limited_decodes_and_respects_limit() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(48_000, 2.0), 48_000); + let (full_pcm, sr_full) = decode_mono_pcm_limited(&wav, None).expect("full decode"); + assert_eq!(sr_full, 48_000.0); + assert!( + full_pcm.len() >= 95_000, + "2 seconds at 48kHz should decode close to 96k samples" + ); + + let (limited_pcm, sr_limited) = + decode_mono_pcm_limited(&wav, Some(0.25)).expect("limited decode"); + assert_eq!(sr_limited, 48_000.0); + assert!( + (11_500..=12_500).contains(&limited_pcm.len()), + "0.25 seconds at 48kHz should decode ~12k samples, got {}", + limited_pcm.len() + ); + assert!(limited_pcm.len() < full_pcm.len()); + } + + #[test] + fn decode_mono_pcm_limited_rejects_empty_buffer() { + let err = decode_mono_pcm_limited(&[], Some(1.0)).unwrap_err(); + assert!(err.contains("empty audio buffer")); + } + + #[test] + fn decode_mono_pcm_window_decodes_center_slice() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100); + let (window_pcm, sr) = decode_mono_pcm_window(&wav, 0.75, 0.5).expect("window decode"); + assert_eq!(sr, 44_100.0); + assert!( + (20_000..=24_000).contains(&window_pcm.len()), + "0.5 seconds at 44.1kHz should decode ~22k samples, got {}", + window_pcm.len() + ); + } + + #[test] + fn decode_mono_pcm_window_rejects_invalid_bytes() { + let err = decode_mono_pcm_window(b"not-audio", 0.0, 1.0).unwrap_err(); + assert!( + err.contains("failed to open audio decode session"), + "unexpected error: {err}" + ); + } + + #[test] + fn decode_scan_pcm_supports_waveform_only_mode_without_loudness() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); + let (frames, hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting"); + let scanned = decode_scan_pcm(&wav, 64, frames, hint, None).expect("scan must succeed"); + assert_eq!(scanned.bins.len(), 128); + assert!(scanned.loudness.is_none()); + } + + #[test] + fn decode_scan_pcm_with_loudness_target_returns_loudness_tuple() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); + let (frames, hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting"); + let scanned = decode_scan_pcm(&wav, 64, frames, hint, Some(-14.0)).expect("scan must succeed"); + assert_eq!(scanned.bins.len(), 128); + let (integrated_lufs, true_peak, recommended_gain_db, target_lufs) = + scanned.loudness.expect("loudness tuple must be present"); + assert!(integrated_lufs.is_finite()); + assert!(true_peak.is_finite()); + assert!((-24.0..=24.0).contains(&recommended_gain_db)); + assert_eq!(target_lufs, -14.0); + } + + #[test] + fn decode_scan_pcm_returns_none_for_non_audio_input() { + assert!(decode_scan_pcm(b"nope", 32, 10, None, Some(-14.0)).is_none()); + } + + #[test] + fn seed_from_bytes_reanalyzes_when_waveform_exists_without_loudness() { + let cache = AnalysisCache::open_in_memory(); + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); + let md5 = md5_first_16kb(&wav); + let key = TrackKey { + server_id: "server-a".to_string(), + track_id: "track-reseed".to_string(), + md5_16kb: md5, + }; + cache.touch_track_status(&key, "ready").unwrap(); + cache + .upsert_waveform( + &key, + &WaveformEntry { + bins: vec![8u8; 1000], + bin_count: 500, + is_partial: false, + known_until_sec: 0.0, + duration_sec: 0.0, + updated_at: now_unix_ts(), + }, + ) + .unwrap(); + assert!(!cache.loudness_row_exists_for_key(&key).unwrap()); + + let (outcome, _) = + seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav).unwrap(); + assert_eq!(outcome, SeedFromBytesOutcome::Upserted); + assert!(cache.loudness_row_exists_for_key(&key).unwrap()); + } + + #[test] + fn analysis_pcm_window_handles_negative_and_non_finite_durations() { + let neg = analysis_pcm_window(-42.0, 60.0); + assert_eq!(neg.start_sec, 0.0); + assert_eq!(neg.duration_sec, 60.0); + + let inf = analysis_pcm_window(f64::INFINITY, 60.0); + assert_eq!(inf.start_sec, 0.0); + assert!(!inf.duration_sec.is_finite()); + } + + #[test] + fn decode_mono_pcm_window_rejects_empty_buffer() { + let err = decode_mono_pcm_window(&[], 0.0, 1.0).unwrap_err(); + assert!(err.contains("empty audio buffer")); + } + + #[test] + fn decode_mono_pcm_limited_rejects_invalid_bytes() { + let err = decode_mono_pcm_limited(b"not-audio", Some(0.5)).unwrap_err(); + assert!(err.contains("failed to open audio decode session")); + } + + #[test] + fn decode_mono_pcm_limited_ignores_non_positive_or_non_finite_cap() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); + let (full_a, _) = decode_mono_pcm_limited(&wav, None).unwrap(); + let (full_b, _) = decode_mono_pcm_limited(&wav, Some(0.0)).unwrap(); + let (full_c, _) = decode_mono_pcm_limited(&wav, Some(f64::NAN)).unwrap(); + assert_eq!(full_a.len(), full_b.len()); + assert_eq!(full_a.len(), full_c.len()); + } + + #[test] + fn decode_scan_pcm_returns_none_when_no_frames_decoded() { + let wav = build_mono_pcm16_wav(&[], 44_100); + assert!(analyze_loudness_and_waveform(&wav, -14.0, 64).is_none()); + } + + #[test] + fn decode_scan_pcm_ignores_oversized_timeline_hint() { + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); + let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting"); + let scanned = decode_scan_pcm(&wav, 64, frames, Some(frames * 10), None).unwrap(); + assert_eq!(scanned.bins.len(), 128); + } + + #[test] + fn seed_from_bytes_execute_returns_no_cache_without_registered_state() { + let app = tauri::test::mock_app(); + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.25), 44_100); + let handle = app.handle().clone(); + let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav) + .expect("seed execute should return a graceful skip"); + assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache); + assert_eq!(timings.seed_ms, 0); + assert_eq!(timings.bpm_ms, 0); + } + + #[test] + fn seed_from_bytes_execute_runs_with_registered_cache() { + let app = tauri::test::mock_app(); + app.manage(AnalysisCache::open_in_memory()); + let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100); + let handle = app.handle().clone(); + + let (first, timings_first) = + seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav).unwrap(); + assert_eq!(first, SeedFromBytesOutcome::Upserted); + assert!(timings_first.seed_ms <= 30_000); + + let (second, timings_second) = + seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav).unwrap(); + assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit); + assert!(timings_second.seed_ms <= 30_000); + } } diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs index 11dacb72..b9337050 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs @@ -6,4 +6,6 @@ pub use compute::{ decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target, seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome, }; -pub use store::{AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry}; +pub use store::{ + AnalysisCache, AnalysisDeleteServerReport, LoudnessEntry, TrackKey, WaveformEntry, +}; 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 095c093c..b72a5359 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; +use std::{fs, io}; use rusqlite::{params, Connection, OptionalExtension}; use tauri::Manager; @@ -18,7 +19,10 @@ const MIGRATION_002_SERVER_ID: &str = include_str!("../../migrations/002_server_ /// 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)]; +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 { @@ -31,16 +35,14 @@ 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). + /// App server id this analysis belongs to (scheme-less host/path key). pub server_id: String, pub track_id: String, pub md5_16kb: String, } /// Waveform / loudness rows present for a specific content fingerprint -/// (`md5_16kb`), after track-id variant + legacy server fallback. +/// (`md5_16kb`), after track-id variant checks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ContentCacheCoverage { pub has_waveform: bool, @@ -83,6 +85,13 @@ pub struct LoudnessSnapshot { pub updated_at: i64, } +#[derive(Debug, Clone, Copy)] +pub struct AnalysisDeleteServerReport { + pub analysis_tracks: u64, + pub waveforms: u64, + pub loudness: u64, +} + pub struct AnalysisCache { conn: Mutex, } @@ -109,7 +118,7 @@ pub(super) fn now_unix_ts() -> i64 { } impl AnalysisCache { - pub fn init(app: &tauri::AppHandle) -> Result { + pub fn init(app: &tauri::AppHandle) -> Result { let db_path = analysis_db_path(app)?; if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; @@ -118,6 +127,7 @@ impl AnalysisCache { let mut conn = Connection::open(&db_path).map_err(|e| e.to_string())?; configure_connection(&conn).map_err(|e| e.to_string())?; run_migrations(&mut conn).map_err(|e| e.to_string())?; + checkpoint_wal_conn(&conn, "open").map_err(|e| e.to_string())?; Ok(Self { conn: Mutex::new(conn) }) } @@ -134,15 +144,13 @@ impl AnalysisCache { let mut conn = Connection::open_in_memory().expect("in-memory connection"); conn.pragma_update(None, "foreign_keys", "ON").expect("pragma foreign_keys"); run_migrations(&mut conn).expect("schema migration"); + let _ = checkpoint_wal_conn(&conn, "open"); Self { conn: Mutex::new(conn) } } /// 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. + /// variant) scoped to one server. A reseed on server A must not delete + /// server B's analysis for the same bare `track_id`. pub fn delete_loudness_for_track_id(&self, server_id: &str, track_id: &str) -> Result { if track_id.trim().is_empty() { return Ok(0); @@ -155,7 +163,7 @@ impl AnalysisCache { for tid in track_id_cache_variants(track_id) { let n = conn .execute( - "DELETE FROM loudness_cache WHERE track_id = ?1 AND server_id IN (?2, '')", + "DELETE FROM loudness_cache WHERE track_id = ?1 AND server_id = ?2", params![tid, server_id], ) .map_err(|e| e.to_string())?; @@ -165,8 +173,8 @@ impl AnalysisCache { } /// 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. + /// variant) scoped to one server. 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); @@ -179,7 +187,7 @@ impl AnalysisCache { for tid in track_id_cache_variants(track_id) { let n = conn .execute( - "DELETE FROM waveform_cache WHERE track_id = ?1 AND server_id IN (?2, '')", + "DELETE FROM waveform_cache WHERE track_id = ?1 AND server_id = ?2", params![tid, server_id], ) .map_err(|e| e.to_string())?; @@ -200,6 +208,138 @@ impl AnalysisCache { Ok(n as u64) } + /// Remove all analysis cache entries for a specific server id. + pub fn delete_all_for_server( + &self, + server_id: &str, + ) -> Result { + let mut conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + let waveforms = tx + .execute("DELETE FROM waveform_cache WHERE server_id = ?1", params![server_id]) + .map_err(|e| e.to_string())?; + let loudness = tx + .execute("DELETE FROM loudness_cache WHERE server_id = ?1", params![server_id]) + .map_err(|e| e.to_string())?; + let analysis_tracks = tx + .execute("DELETE FROM analysis_track WHERE server_id = ?1", params![server_id]) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(AnalysisDeleteServerReport { + analysis_tracks: analysis_tracks as u64, + waveforms: waveforms as u64, + loudness: loudness as u64, + }) + } + + pub fn checkpoint_wal(&self, op: &'static str) -> Result<(), String> { + let conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + checkpoint_wal_conn(&conn, op).map_err(|e| e.to_string()) + } + + /// Atomically switch analysis sqlite file while replacing the held + /// connection so runtime writers cannot continue on the old inode. + pub fn swap_database_file( + &self, + active_path: &Path, + destination_path: &Path, + ) -> Result, String> { + if !destination_path.exists() { + return Ok(None); + } + let mut conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let old_conn = std::mem::replace(&mut *conn, tmp); + drop(old_conn); + + let backup = active_path.with_file_name(format!( + "{}.backup-pre-indexkey", + active_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("audio-analysis.sqlite") + )); + remove_db_with_sidecars(&backup).ok(); + if active_path.exists() { + fs::rename(active_path, &backup).map_err(|e| e.to_string())?; + move_sidecar(active_path, &backup, "-wal")?; + move_sidecar(active_path, &backup, "-shm")?; + } + if let Err(err) = fs::rename(destination_path, active_path) { + if backup.exists() { + let _ = fs::rename(&backup, active_path); + let _ = move_sidecar(&backup, active_path, "-wal"); + let _ = move_sidecar(&backup, active_path, "-shm"); + } + return Err(err.to_string()); + } + let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?; + configure_connection(&reopened).map_err(|e| e.to_string())?; + run_migrations(&mut reopened).map_err(|e| e.to_string())?; + *conn = reopened; + Ok(Some(backup)) + } + + pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> { + let mut conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let old_conn = std::mem::replace(&mut *conn, tmp); + drop(old_conn); + + if active_path.exists() { + remove_db_with_sidecars(active_path)?; + } + if backup_path.exists() { + fs::rename(backup_path, active_path).map_err(|e| e.to_string())?; + move_sidecar(backup_path, active_path, "-wal")?; + move_sidecar(backup_path, active_path, "-shm")?; + } + let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?; + configure_connection(&reopened).map_err(|e| e.to_string())?; + run_migrations(&mut reopened).map_err(|e| e.to_string())?; + *conn = reopened; + Ok(()) + } + + /// Drop analysis rows written under legacy server ids (profile UUIDs). + pub fn migrate_server_keys( + &self, + mappings: &[(String, String)], + ) -> Result<(), String> { + let mut conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + for (legacy, key) in mappings { + let legacy = legacy.trim(); + let key = key.trim(); + if legacy.is_empty() || key.is_empty() || legacy == key { + continue; + } + tx.execute("DELETE FROM waveform_cache WHERE server_id = ?1", params![legacy]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM loudness_cache WHERE server_id = ?1", params![legacy]) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM analysis_track WHERE server_id = ?1", params![legacy]) + .map_err(|e| e.to_string())?; + } + tx.commit().map_err(|e| e.to_string())?; + Ok(()) + } + pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> { let now = now_unix_ts(); let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; @@ -321,8 +461,7 @@ impl AnalysisCache { } /// Lookup waveform + loudness for an exact content fingerprint, trying bare / - /// `stream:` track-id variants and the legacy `''` server pool (with lazy - /// re-tag onto `server_id` when a legacy hit occurs). + /// `stream:` track-id variants. pub fn content_cache_coverage( &self, server_id: &str, @@ -331,7 +470,6 @@ impl AnalysisCache { ) -> Result { let mut has_waveform = false; let mut has_loudness = false; - let mut relabel = false; for tid in track_id_cache_variants(track_id) { if !server_id.is_empty() { let key = TrackKey { @@ -346,26 +484,6 @@ impl AnalysisCache { has_loudness = true; } } - let legacy = TrackKey { - server_id: String::new(), - track_id: tid, - md5_16kb: md5_16kb.to_string(), - }; - if self.get_waveform(&legacy)?.is_some() { - has_waveform = true; - if !server_id.is_empty() { - relabel = true; - } - } - if self.loudness_row_exists_for_key(&legacy)? { - has_loudness = true; - if !server_id.is_empty() { - relabel = true; - } - } - } - if relabel { - let _ = self.relabel_legacy_to_server(server_id, track_id); } Ok(ContentCacheCoverage { has_waveform, @@ -400,54 +518,29 @@ impl AnalysisCache { Ok(exists != 0) } - /// 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 `''`. + /// Latest waveform for `(server_id, track_id)` (tries both id variants). 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())?; - 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) + query_latest_waveform_scoped(&conn, server_id, track_id) } - /// Latest `md5_16kb` fingerprint for `(server_id, track_id)` with legacy fallback. + /// Latest `md5_16kb` fingerprint for `(server_id, track_id)`. pub fn get_latest_md5_16kb_for_track( &self, server_id: &str, track_id: &str, ) -> Result, String> { let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; - if let Some(md5) = query_latest_md5_16kb_scoped(&conn, server_id, track_id)? { - return Ok(Some(md5)); - } - if !server_id.is_empty() { - if let Some(md5) = query_latest_md5_16kb_scoped(&conn, "", track_id)? { - let _ = relabel_legacy_to_server(&conn, server_id, track_id); - return Ok(Some(md5)); - } - } - Ok(None) + query_latest_md5_16kb_scoped(&conn, server_id, track_id) } - /// 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`. + /// Both waveform and loudness rows exist for this `(server_id, track_id)` — + /// a CPU seed from bytes/file would only decode the file to immediately skip + /// with `SkippedWaveformCacheHit`. pub fn cpu_seed_redundant_for_track(&self, server_id: &str, track_id: &str) -> Result { Ok( self.get_latest_waveform_for_track(server_id, track_id)?.is_some() @@ -455,37 +548,16 @@ impl AnalysisCache { ) } - /// Latest loudness for `(server_id, track_id)` with the same legacy fallback + - /// lazy re-tag behaviour as [`Self::get_latest_waveform_for_track`]. + /// Latest loudness for `(server_id, track_id)`. 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())?; - 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) + query_latest_loudness_scoped(&conn, server_id, track_id) } - /// 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 @@ -604,55 +676,120 @@ fn query_latest_loudness_scoped( 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], - )?; +fn analysis_db_path(app: &tauri::AppHandle) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + let db_dir = base.join("databases").join("analysis"); + let db_path = db_dir.join("audio-analysis.sqlite"); + let legacy_data = base.join("audio-analysis.sqlite"); + let legacy_config = app + .path() + .app_config_dir() + .map_err(|e| e.to_string())? + .join("audio-analysis.sqlite"); + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + if db_path.exists() { + cleanup_legacy_db_if_present(&legacy_data, &db_path)?; + cleanup_legacy_db_if_present(&legacy_config, &db_path)?; + return Ok(db_path); + } + + if legacy_data.exists() { + migrate_db_file(&legacy_data, &db_path).map_err(|e| e.to_string())?; + migrate_db_sidecar(&legacy_data, &db_path, "-wal").map_err(|e| e.to_string())?; + migrate_db_sidecar(&legacy_data, &db_path, "-shm").map_err(|e| e.to_string())?; + } else if legacy_config.exists() { + migrate_db_file(&legacy_config, &db_path).map_err(|e| e.to_string())?; + migrate_db_sidecar(&legacy_config, &db_path, "-wal").map_err(|e| e.to_string())?; + migrate_db_sidecar(&legacy_config, &db_path, "-shm").map_err(|e| e.to_string())?; + } + cleanup_legacy_db_if_present(&legacy_data, &db_path)?; + cleanup_legacy_db_if_present(&legacy_config, &db_path)?; + + Ok(db_path) +} + +fn cleanup_legacy_db_if_present(legacy_path: &Path, active_path: &Path) -> Result<(), String> { + if legacy_path == active_path { + return Ok(()); + } + remove_db_with_sidecars(legacy_path) +} + +fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> { + let (busy, log, checkpointed): (i32, i32, i32) = + conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + if busy != 0 { + crate::app_eprintln!( + "[analysis-db] wal checkpoint busy op={op} busy={busy} log={log} checkpointed={checkpointed}" + ); } Ok(()) } -fn analysis_db_path(app: &tauri::AppHandle) -> Result { - let base = app - .path() - .app_config_dir() - .map_err(|e| e.to_string())?; - Ok(base.join("audio-analysis.sqlite")) +fn migrate_db_file(from: &Path, to: &Path) -> io::Result<()> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + match fs::rename(from, to) { + Ok(()) => Ok(()), + Err(_) => { + fs::copy(from, to)?; + fs::remove_file(from)?; + Ok(()) + } + } +} + +fn migrate_db_sidecar(from: &Path, to: &Path, suffix: &str) -> io::Result<()> { + let from_path = PathBuf::from(format!("{}{}", from.display(), suffix)); + if !from_path.exists() { + return Ok(()); + } + let to_path = PathBuf::from(format!("{}{}", to.display(), suffix)); + if let Some(parent) = to_path.parent() { + fs::create_dir_all(parent)?; + } + match fs::rename(&from_path, &to_path) { + Ok(()) => Ok(()), + Err(_) => { + fs::copy(&from_path, &to_path)?; + fs::remove_file(&from_path)?; + Ok(()) + } + } +} + +fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> { + let from = PathBuf::from(format!("{}{}", from_base.display(), suffix)); + if !from.exists() { + return Ok(()); + } + let to = PathBuf::from(format!("{}{}", to_base.display(), suffix)); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::rename(from, to).map_err(|e| e.to_string()) +} + +fn remove_db_with_sidecars(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix)); + if sidecar.exists() { + fs::remove_file(sidecar).map_err(|e| e.to_string())?; + } + } + Ok(()) } fn configure_connection(conn: &Connection) -> rusqlite::Result<()> { @@ -754,10 +891,11 @@ pub(crate) fn run_migrations_with( #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; fn key(track_id: &str) -> TrackKey { TrackKey { - server_id: String::new(), + server_id: "server-a".to_string(), track_id: track_id.to_string(), md5_16kb: "deadbeef".to_string(), } @@ -966,7 +1104,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("server-a", "abc").unwrap(); assert!(got.is_some(), "bare-id lookup must find stream-prefixed row"); } @@ -976,7 +1114,9 @@ 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("server-a", "stream:abc") + .unwrap(); assert!(got.is_some(), "stream-prefixed lookup must find bare row"); } @@ -988,16 +1128,20 @@ 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("server-a", "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("server-a", "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("server-a", "abc") + .unwrap()); } // ── deletes ─────────────────────────────────────────────────────────────── @@ -1012,7 +1156,9 @@ 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("server-a", "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()); @@ -1028,7 +1174,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("server-a", "abc").unwrap(); assert_eq!(deleted, 2); assert!(cache.get_waveform(&bare).unwrap().is_none()); assert!(cache.get_waveform(&prefixed).unwrap().is_none()); @@ -1046,95 +1192,29 @@ mod tests { #[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. + // bare track_id. 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] { + for k in [&on_a, &on_b] { 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_eq!(deleted, 1, "server-a 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_eq!(deleted_l, 1); 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] fn delete_all_waveforms_removes_every_row() { let cache = AnalysisCache::open_in_memory(); @@ -1185,57 +1265,6 @@ mod tests { 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(); @@ -1279,6 +1308,32 @@ mod tests { dir.join(format!("audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak")) } + fn sqlite_sidecar(path: &Path, suffix: &str) -> PathBuf { + PathBuf::from(format!("{}{}", path.display(), suffix)) + } + + fn open_file_cache(db_path: &Path) -> AnalysisCache { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let mut conn = Connection::open(db_path).unwrap(); + configure_connection(&conn).unwrap(); + run_migrations(&mut conn).unwrap(); + AnalysisCache { + conn: Mutex::new(conn), + } + } + + fn unique_temp_file(tag: &str) -> PathBuf { + 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); + std::env::temp_dir().join(format!("psysonic-analysis-{tag}-{nanos}-{n}.sqlite")) + } + #[test] fn backup_snapshots_pre_v2_db_and_overwrites_stale() { let dir = unique_temp_dir("bkp-create"); @@ -1338,4 +1393,284 @@ mod tests { ); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn content_cache_coverage_tracks_partial_and_complete_state() { + let cache = AnalysisCache::open_in_memory(); + let k = key("abc"); + cache.touch_track_status(&k, "queued").unwrap(); + + let none = cache.content_cache_coverage("server-a", "abc", "deadbeef").unwrap(); + assert!(!none.has_waveform); + assert!(!none.has_loudness); + assert!(!none.complete()); + + cache.upsert_waveform(&k, &waveform(4, false)).unwrap(); + let only_waveform = cache + .content_cache_coverage("server-a", "stream:abc", "deadbeef") + .unwrap(); + assert!(only_waveform.has_waveform); + assert!(!only_waveform.has_loudness); + assert!(!only_waveform.complete()); + + cache.upsert_loudness(&k, &loudness(-14.0)).unwrap(); + let full = cache.content_cache_coverage("server-a", "abc", "deadbeef").unwrap(); + assert!(full.complete()); + } + + #[test] + fn get_latest_md5_uses_variant_and_filters_empty_values() { + let cache = AnalysisCache::open_in_memory(); + let ok = key("stream:t1"); + cache.touch_track_status(&ok, "ready").unwrap(); + cache.upsert_waveform(&ok, &waveform(4, false)).unwrap(); + + assert_eq!( + cache + .get_latest_md5_16kb_for_track("server-a", "t1") + .unwrap() + .as_deref(), + Some("deadbeef") + ); + + let empty_md5 = TrackKey { + server_id: "server-a".to_string(), + track_id: "t2".to_string(), + md5_16kb: "".to_string(), + }; + cache.touch_track_status(&empty_md5, "ready").unwrap(); + cache.upsert_waveform(&empty_md5, &waveform(4, false)).unwrap(); + assert!( + cache + .get_latest_md5_16kb_for_track("server-a", "t2") + .unwrap() + .is_none(), + "empty md5 rows must be ignored by latest-md5 lookup" + ); + } + + #[test] + fn delete_all_for_server_removes_only_targeted_server_rows() { + let cache = AnalysisCache::open_in_memory(); + let a = key_on("server-a", "t"); + let b = key_on("server-b", "t"); + for k in [&a, &b] { + cache.touch_track_status(k, "ready").unwrap(); + cache.upsert_waveform(k, &waveform(4, false)).unwrap(); + cache.upsert_loudness(k, &loudness(-14.0)).unwrap(); + } + + let report = cache.delete_all_for_server("server-a").unwrap(); + assert_eq!(report.analysis_tracks, 1); + assert_eq!(report.waveforms, 1); + assert_eq!(report.loudness, 1); + assert!(cache.get_waveform(&a).unwrap().is_none()); + assert!(cache.get_waveform(&b).unwrap().is_some()); + assert!(cache.loudness_row_exists_for_key(&b).unwrap()); + } + + #[test] + fn migrate_server_keys_drops_only_legacy_rows() { + let cache = AnalysisCache::open_in_memory(); + let legacy = key_on("legacy-uuid", "t"); + let modern = key_on("modern-index-key", "t"); + for k in [&legacy, &modern] { + cache.touch_track_status(k, "ready").unwrap(); + cache.upsert_waveform(k, &waveform(4, false)).unwrap(); + cache.upsert_loudness(k, &loudness(-14.0)).unwrap(); + } + + cache + .migrate_server_keys(&[ + ("legacy-uuid".to_string(), "modern-index-key".to_string()), + ("".to_string(), "skip".to_string()), + ("same".to_string(), "same".to_string()), + ]) + .unwrap(); + + assert!(cache.get_waveform(&legacy).unwrap().is_none()); + assert!(cache.get_waveform(&modern).unwrap().is_some()); + } + + #[test] + fn swap_database_file_and_restore_backup_roundtrip() { + let active_path = unique_temp_file("swap-active"); + let destination_path = unique_temp_file("swap-dst"); + let backup_path = active_path.with_file_name(format!( + "{}.backup-pre-indexkey", + active_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("audio-analysis.sqlite") + )); + + let cache = open_file_cache(&active_path); + let old_key = key_on("server-a", "old"); + cache.touch_track_status(&old_key, "ready").unwrap(); + cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap(); + + { + let dst = open_file_cache(&destination_path); + let new_key = key_on("server-a", "new"); + dst.touch_track_status(&new_key, "ready").unwrap(); + dst.upsert_waveform(&new_key, &waveform(4, false)).unwrap(); + dst.checkpoint_wal("dst").unwrap(); + } + + let backup = cache + .swap_database_file(&active_path, &destination_path) + .unwrap() + .expect("backup path must be returned"); + assert_eq!(backup, backup_path); + assert!( + cache + .get_waveform(&key_on("server-a", "new")) + .unwrap() + .is_some(), + "cache must reopen on swapped destination DB" + ); + assert!(!destination_path.exists(), "destination DB must be moved into active path"); + assert!(backup_path.exists(), "previous active DB must be moved to backup path"); + + cache + .restore_database_backup(&backup_path, &active_path) + .unwrap(); + assert!( + cache + .get_waveform(&key_on("server-a", "old")) + .unwrap() + .is_some(), + "restore must bring old DB back" + ); + assert!( + cache + .get_waveform(&key_on("server-a", "new")) + .unwrap() + .is_none(), + "restored DB must not contain swapped-in rows" + ); + + let _ = remove_db_with_sidecars(&active_path); + let _ = remove_db_with_sidecars(&backup_path); + } + + #[test] + fn swap_database_file_returns_none_when_destination_missing() { + let active_path = unique_temp_file("swap-none-active"); + let missing_destination = unique_temp_file("swap-none-dst"); + let cache = open_file_cache(&active_path); + let backup = cache + .swap_database_file(&active_path, &missing_destination) + .unwrap(); + assert!(backup.is_none()); + let _ = remove_db_with_sidecars(&active_path); + } + + #[test] + fn migrate_db_helpers_move_and_cleanup_sidecars() { + let from = unique_temp_file("migrate-from"); + let to = unique_temp_file("migrate-to"); + std::fs::write(&from, b"sqlite-bytes").unwrap(); + std::fs::write(sqlite_sidecar(&from, "-wal"), b"wal").unwrap(); + std::fs::write(sqlite_sidecar(&from, "-shm"), b"shm").unwrap(); + + migrate_db_file(&from, &to).unwrap(); + assert!(to.exists()); + assert!(!from.exists()); + + migrate_db_sidecar(&from, &to, "-wal").unwrap(); + migrate_db_sidecar(&from, &to, "-shm").unwrap(); + assert!(sqlite_sidecar(&to, "-wal").exists()); + assert!(sqlite_sidecar(&to, "-shm").exists()); + + let moved_to = unique_temp_file("migrate-moved"); + std::fs::write(&moved_to, b"sqlite-bytes-2").unwrap(); + std::fs::write(sqlite_sidecar(&moved_to, "-wal"), b"wal2").unwrap(); + move_sidecar(&moved_to, &to, "-wal").unwrap(); + assert!(!sqlite_sidecar(&moved_to, "-wal").exists()); + assert!(sqlite_sidecar(&to, "-wal").exists()); + + cleanup_legacy_db_if_present(&to, &to).unwrap(); + cleanup_legacy_db_if_present(&to, &moved_to).unwrap(); + assert!(!to.exists()); + assert!(!sqlite_sidecar(&to, "-wal").exists()); + assert!(!sqlite_sidecar(&to, "-shm").exists()); + } + + #[test] + fn run_migrations_with_applies_unsorted_versions_once() { + let mut conn = Connection::open_in_memory().unwrap(); + let migrations = [ + ( + 3, + "CREATE TABLE IF NOT EXISTS m3 (id INTEGER PRIMARY KEY);", + ), + ( + 1, + "CREATE TABLE IF NOT EXISTS m1 (id INTEGER PRIMARY KEY);", + ), + ( + 2, + "CREATE TABLE IF NOT EXISTS m2 (id INTEGER PRIMARY KEY);", + ), + ]; + run_migrations_with(&mut conn, &migrations).unwrap(); + 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, vec![1, 2, 3]); + } + + #[test] + fn checkpoint_wal_smoke_test() { + let cache = AnalysisCache::open_in_memory(); + cache.checkpoint_wal("test").unwrap(); + } + + #[test] + fn sidecar_helpers_are_noop_when_source_is_missing() { + let from = unique_temp_file("sidecar-missing-from"); + let to = unique_temp_file("sidecar-missing-to"); + std::fs::write(&to, b"db").unwrap(); + + migrate_db_sidecar(&from, &to, "-wal").unwrap(); + migrate_db_sidecar(&from, &to, "-shm").unwrap(); + move_sidecar(&from, &to, "-wal").unwrap(); + move_sidecar(&from, &to, "-shm").unwrap(); + remove_db_with_sidecars(&from).unwrap(); + + assert!(to.exists(), "destination DB stays intact"); + let _ = remove_db_with_sidecars(&to); + } + + #[test] + fn restore_database_backup_keeps_active_when_backup_missing() { + let active_path = unique_temp_file("restore-active"); + let backup_path = unique_temp_file("restore-missing-backup"); + let cache = open_file_cache(&active_path); + let k = key_on("server-a", "active-track"); + cache.touch_track_status(&k, "ready").unwrap(); + cache.upsert_waveform(&k, &waveform(4, false)).unwrap(); + + cache + .restore_database_backup(&backup_path, &active_path) + .unwrap(); + assert!(cache.get_waveform(&k).unwrap().is_none()); + + let _ = remove_db_with_sidecars(&active_path); + } + + #[test] + fn init_opens_app_scoped_database_path() { + let app = tauri::test::mock_app(); + let handle = app.handle().clone(); + let cache = AnalysisCache::init(&handle).expect("analysis cache init with mock app"); + cache.checkpoint_wal("init-test").unwrap(); + } } diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_perf.rs b/src-tauri/crates/psysonic-analysis/src/analysis_perf.rs new file mode 100644 index 00000000..0fc03b37 --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/src/analysis_perf.rs @@ -0,0 +1,42 @@ +//! Per-track analysis timing events for the Performance Probe overlay. + +use tauri::{AppHandle, Emitter}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AnalysisSeedTimings { + pub seed_ms: u64, + pub bpm_ms: u64, +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisTrackPerfPayload { + pub track_id: String, + pub fetch_ms: u64, + pub seed_ms: u64, + pub bpm_ms: u64, + pub total_ms: u64, +} + +pub fn emit_analysis_track_perf( + app: &AppHandle, + track_id: &str, + fetch_ms: u64, + seed_ms: u64, + bpm_ms: u64, +) { + let total_ms = fetch_ms.saturating_add(seed_ms).saturating_add(bpm_ms); + if total_ms == 0 { + return; + } + let _ = app.emit( + "analysis:track-perf", + AnalysisTrackPerfPayload { + track_id: track_id.to_string(), + fetch_ms, + seed_ms, + bpm_ms, + total_ms, + }, + ); +} diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 04aebbca..37ad5169 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -1,4 +1,5 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tauri::{Emitter, Manager}; @@ -7,6 +8,8 @@ use psysonic_core::user_agent::subsonic_wire_user_agent; use crate::analysis_cache; +use crate::analysis_perf::{emit_analysis_track_perf, AnalysisSeedTimings}; + #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct WaveformUpdatedPayload { @@ -14,17 +17,92 @@ pub struct WaveformUpdatedPayload { pub is_partial: bool, } +pub const ANALYSIS_PIPELINE_PARALLELISM_MIN: usize = 1; +pub const ANALYSIS_PIPELINE_PARALLELISM_MAX: usize = 20; +pub const ANALYSIS_PIPELINE_PARALLELISM_DEFAULT: usize = 1; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AnalysisTierCounts { + pub high: usize, + pub middle: usize, + pub low: usize, +} + +impl AnalysisTierCounts { + pub fn total(&self) -> usize { + self.high + self.middle + self.low + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisPipelineQueueStatsDto { + pub pipeline_workers: u32, + pub http_queued: usize, + pub http_queued_high: usize, + pub http_queued_middle: usize, + pub http_queued_low: usize, + pub http_download_active: usize, + pub http_download_active_high: usize, + pub http_download_active_middle: usize, + pub http_download_active_low: usize, + pub cpu_queued: usize, + pub cpu_queued_high: usize, + pub cpu_queued_middle: usize, + pub cpu_queued_low: usize, + pub cpu_decode_active: usize, + pub cpu_decode_active_high: usize, + pub cpu_decode_active_middle: usize, + pub cpu_decode_active_low: usize, +} + +pub fn clamp_pipeline_parallelism(workers: usize) -> usize { + workers.clamp( + ANALYSIS_PIPELINE_PARALLELISM_MIN, + ANALYSIS_PIPELINE_PARALLELISM_MAX, + ) +} + +/// Last requested worker count (applied when lazy-init queues and on live updates). +static REQUESTED_PIPELINE_PARALLELISM: AtomicUsize = + AtomicUsize::new(ANALYSIS_PIPELINE_PARALLELISM_DEFAULT); + +fn requested_pipeline_parallelism() -> usize { + clamp_pipeline_parallelism(REQUESTED_PIPELINE_PARALLELISM.load(Ordering::Relaxed)) +} + // ─── HTTP backfill queue: download tracks + seed analysis cache ────────────── +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum AnalysisBackfillPriority { + Low = 0, + Middle = 1, + High = 2, +} + +impl AnalysisBackfillPriority { + pub fn from_optional_str(raw: Option<&str>) -> Option { + let s = raw?.trim(); + if s.is_empty() { + return None; + } + match s.to_ascii_lowercase().as_str() { + "high" => Some(Self::High), + "middle" => Some(Self::Middle), + "low" => Some(Self::Low), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnalysisBackfillEnqueueKind { - /// New job at the tail of the queue. - NewBack, - /// New job for the currently playing track (head). - NewFront, - /// Same track was already waiting; moved to head with the latest URL. - ReorderedFront, - /// Low-priority duplicate while the track is already queued or running. + NewLow, + NewMiddle, + NewHigh, + /// Same track was already waiting; moved to a higher tier with the latest URL. + ReorderedHigher, + /// Same or lower priority while the track is already queued or running. DuplicateSkipped, /// High-priority request but that track is already being downloaded+seeded. RunningSkipped, @@ -37,27 +115,117 @@ type BackfillJob = (String, String, String); #[derive(Default)] pub struct AnalysisBackfillQueueState { - pub deque: VecDeque, - /// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque). - pub in_progress: Option, + high: VecDeque, + middle: VecDeque, + low: VecDeque, + /// Active HTTP downloads keyed by track id (tier kept for pipeline stats). + pub in_progress: HashMap, } 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) + fn queued_len(&self) -> usize { + self.high.len() + self.middle.len() + self.low.len() } - fn try_pop_next(&mut self) -> Option { - let job = self.deque.pop_front()?; - self.in_progress = Some(job.0.clone()); - Some(job) + fn queued_tier_counts(&self) -> AnalysisTierCounts { + AnalysisTierCounts { + high: self.high.len(), + middle: self.middle.len(), + low: self.low.len(), + } + } + + fn in_progress_tier_counts(&self) -> AnalysisTierCounts { + let mut counts = AnalysisTierCounts::default(); + for tier in self.in_progress.values() { + match tier { + AnalysisBackfillPriority::High => counts.high += 1, + AnalysisBackfillPriority::Middle => counts.middle += 1, + AnalysisBackfillPriority::Low => counts.low += 1, + } + } + counts + } + + fn tier_deque(&self, tier: AnalysisBackfillPriority) -> &VecDeque { + match tier { + AnalysisBackfillPriority::High => &self.high, + AnalysisBackfillPriority::Middle => &self.middle, + AnalysisBackfillPriority::Low => &self.low, + } + } + + fn tier_deque_mut(&mut self, tier: AnalysisBackfillPriority) -> &mut VecDeque { + match tier { + AnalysisBackfillPriority::High => &mut self.high, + AnalysisBackfillPriority::Middle => &mut self.middle, + AnalysisBackfillPriority::Low => &mut self.low, + } + } + + fn locate_queued(&self, tid: &str) -> Option { + [ + AnalysisBackfillPriority::High, + AnalysisBackfillPriority::Middle, + AnalysisBackfillPriority::Low, + ] + .into_iter() + .find(|&tier| { + self + .tier_deque(tier) + .iter() + .any(|(t, _, _)| t.as_str() == tid) + }) + } + + fn remove_queued(&mut self, tid: &str) -> Option { + for tier in [ + AnalysisBackfillPriority::High, + AnalysisBackfillPriority::Middle, + AnalysisBackfillPriority::Low, + ] { + if let Some(pos) = self + .tier_deque(tier) + .iter() + .position(|(t, _, _)| t.as_str() == tid) + { + return self.tier_deque_mut(tier).remove(pos); + } + } + None + } + + fn push_new(&mut self, priority: AnalysisBackfillPriority, job: BackfillJob) { + match priority { + AnalysisBackfillPriority::High => self.high.push_front(job), + AnalysisBackfillPriority::Middle => self.middle.push_back(job), + AnalysisBackfillPriority::Low => self.low.push_back(job), + } + } + + fn is_reserved(&self, tid: &str) -> bool { + self.in_progress.contains_key(tid) || self.locate_queued(tid).is_some() + } + + fn try_pop_next(&mut self, max_concurrent: usize) -> Option { + if self.in_progress.len() >= max_concurrent { + return None; + } + for tier in [ + AnalysisBackfillPriority::High, + AnalysisBackfillPriority::Middle, + AnalysisBackfillPriority::Low, + ] { + if let Some(job) = self.tier_deque_mut(tier).pop_front() { + self.in_progress.insert(job.0.clone(), tier); + return Some(job); + } + } + None } fn finish_job(&mut self, tid: &str) { - if self.in_progress.as_deref() == Some(tid) { - self.in_progress = None; - } + self.in_progress.remove(tid); } pub fn enqueue( @@ -65,46 +233,110 @@ impl AnalysisBackfillQueueState { server_id: String, tid: String, url: String, - high_priority: bool, + priority: AnalysisBackfillPriority, ) -> AnalysisBackfillEnqueueKind { let tref = tid.as_str(); + if !self.is_reserved(tref) && analysis_track_in_cpu_pipeline(tref) { + return AnalysisBackfillEnqueueKind::DuplicateSkipped; + } if self.is_reserved(tref) { - if !high_priority { + if self.in_progress.contains_key(tref) { + if priority == AnalysisBackfillPriority::High { + return AnalysisBackfillEnqueueKind::RunningSkipped; + } return AnalysisBackfillEnqueueKind::DuplicateSkipped; } - if self.in_progress.as_deref() == Some(tref) { - return AnalysisBackfillEnqueueKind::RunningSkipped; + let existing = self.locate_queued(tref).unwrap_or(AnalysisBackfillPriority::Low); + if priority <= existing { + return AnalysisBackfillEnqueueKind::DuplicateSkipped; } - 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, server_id)); - AnalysisBackfillEnqueueKind::NewFront - } else { - self.deque.push_back((tid, url, server_id)); - AnalysisBackfillEnqueueKind::NewBack + self.remove_queued(tref); + self.push_new(priority, (tid, url, server_id)); + return AnalysisBackfillEnqueueKind::ReorderedHigher; } + let kind = match priority { + AnalysisBackfillPriority::High => AnalysisBackfillEnqueueKind::NewHigh, + AnalysisBackfillPriority::Middle => AnalysisBackfillEnqueueKind::NewMiddle, + AnalysisBackfillPriority::Low => AnalysisBackfillEnqueueKind::NewLow, + }; + self.push_new(priority, (tid, url, server_id)); + kind } - 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())); - before.saturating_sub(self.deque.len()) + pub fn prune_queued_not_in( + &mut self, + keep_track_ids: &HashSet<&str>, + server_id: Option<&str>, + ) -> usize { + let before = self.queued_len(); + for tier in [ + AnalysisBackfillPriority::High, + AnalysisBackfillPriority::Middle, + AnalysisBackfillPriority::Low, + ] { + self.tier_deque_mut(tier) + .retain(|(track_id, _, job_server_id)| { + let scoped = server_id.is_some_and(|sid| { + job_server_id.is_empty() || job_server_id == sid + }); + if server_id.is_some() && !scoped { + return true; + } + keep_track_ids.contains(track_id.as_str()) + }); + } + before.saturating_sub(self.queued_len()) } } +/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next). +#[derive(Default)] +pub struct PlaybackPriorityHints { + middle_track_ids: Mutex>, +} + +impl PlaybackPriorityHints { + pub fn set_middle_track_ids( + &self, + ids: impl IntoIterator, + ) { + let mut set = HashSet::new(); + for (server_id, track_id) in ids { + let sid = server_id.trim(); + let tid = track_id.trim(); + if !tid.is_empty() { + set.insert(priority_hint_key(sid, tid)); + } + } + *self.middle_track_ids.lock().unwrap_or_else(|e| e.into_inner()) = set; + } + + pub fn is_middle_priority(&self, server_id: &str, track_id: &str) -> bool { + self.middle_track_ids + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&priority_hint_key(server_id, track_id)) + } +} + +fn priority_hint_key(server_id: &str, track_id: &str) -> String { + format!("{server_id}::{track_id}") +} + pub struct AnalysisBackfillShared { pub state: Mutex, wake_tx: tokio::sync::mpsc::UnboundedSender<()>, + max_parallel: AtomicUsize, } impl AnalysisBackfillShared { pub fn ping_worker(&self) { let _ = self.wake_tx.send(()); } + + fn max_parallel(&self) -> usize { + clamp_pipeline_parallelism(self.max_parallel.load(Ordering::Relaxed)) + } } static ANALYSIS_BACKFILL: OnceLock> = OnceLock::new(); @@ -117,10 +349,11 @@ pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc Result { + enqueue_track_analysis_with_fetch(app, server_id, track_id, bytes, priority, 0).await +} + +async fn enqueue_track_analysis_with_fetch( + app: &tauri::AppHandle, + server_id: &str, + track_id: &str, + bytes: &[u8], + priority: AnalysisBackfillPriority, + fetch_ms: u64, ) -> Result { if bytes.is_empty() { return Ok(EnqueueTrackAnalysisOutcome::Complete); @@ -178,7 +422,8 @@ pub async fn enqueue_track_analysis( server_id.to_string(), track_id.to_string(), bytes.to_vec(), - high_priority, + priority, + fetch_ms, ) .await?; return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed); @@ -189,7 +434,10 @@ pub async fn enqueue_track_analysis( track_id, content_hash ); + let bpm_started = std::time::Instant::now(); run_track_enrichment_from_bytes(app, server_id, track_id, bytes).await; + let bpm_ms = bpm_started.elapsed().as_millis() as u64; + emit_analysis_track_perf(app, track_id, fetch_ms, 0, bpm_ms); return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly); } Ok(EnqueueTrackAnalysisOutcome::Complete) @@ -224,7 +472,7 @@ pub async fn enqueue_track_analysis_from_file( server_id: &str, track_id: &str, file_path: &std::path::Path, - high_priority: bool, + priority: AnalysisBackfillPriority, ) -> Result { let bytes = tokio::fs::read(file_path) .await @@ -232,7 +480,7 @@ pub async fn enqueue_track_analysis_from_file( if bytes.is_empty() { return Ok(EnqueueTrackAnalysisOutcome::Complete); } - enqueue_track_analysis(app, server_id, track_id, &bytes, high_priority).await + enqueue_track_analysis(app, server_id, track_id, &bytes, priority).await } /// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`]. @@ -242,23 +490,30 @@ pub async fn enqueue_analysis_seed( track_id: &str, bytes: &[u8], ) -> Result { - let high = analysis_backfill_is_current_track(app, track_id); - let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, high).await?; + let priority = analysis_backfill_resolve_priority(app, server_id, track_id, None); + let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, priority).await?; Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete)) } -async fn analysis_backfill_download_and_seed( - app: &tauri::AppHandle, - server_id: &str, - track_id: &str, - url: &str, -) -> Result { - let client = reqwest::Client::builder() - .user_agent(subsonic_wire_user_agent()) - .timeout(std::time::Duration::from_secs(120)) - .build() +fn analysis_http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .user_agent(subsonic_wire_user_agent()) + .timeout(std::time::Duration::from_secs(120)) + .pool_max_idle_per_host(ANALYSIS_PIPELINE_PARALLELISM_MAX) + .build() + .expect("analysis HTTP client") + }) +} + +async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec, u64), String> { + let fetch_started = std::time::Instant::now(); + let response = analysis_http_client() + .get(url) + .send() + .await .map_err(|e| e.to_string())?; - let response = client.get(url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } @@ -266,7 +521,8 @@ async fn analysis_backfill_download_and_seed( if bytes.is_empty() { return Err("empty response".to_string()); } - enqueue_analysis_seed(app, server_id, track_id, &bytes).await + let fetch_ms = fetch_started.elapsed().as_millis() as u64; + Ok((bytes.to_vec(), fetch_ms)) } async fn analysis_backfill_worker_loop( @@ -278,29 +534,176 @@ async fn analysis_backfill_worker_loop( if wake_rx.recv().await.is_none() { break; } - while let Some((track_id, url, server_id)) = { + spawn_backfill_slots(&app, &shared).await; + } +} + +async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc) { + loop { + let max = shared.max_parallel(); + let job_bundle = { let mut st = shared .state .lock() .unwrap_or_else(|e| e.into_inner()); - st.try_pop_next() - } { - crate::app_deprintln!("[analysis] backfill worker: start track_id={}", track_id); - let result = analysis_backfill_download_and_seed(&app, &server_id, &track_id, &url).await; + st.try_pop_next(max).map(|job| { + let worker_slot = st.in_progress.len(); + (job, worker_slot) + }) + }; + let Some(((track_id, url, server_id), worker_slot)) = job_bundle else { + break; + }; + crate::app_deprintln!( + "[analysis] backfill worker={}/{}: start track_id={}", + worker_slot, + max, + track_id + ); + let app = app.clone(); + let shared = shared.clone(); + tauri::async_runtime::spawn(async move { + let download_result = analysis_backfill_download_bytes(&url).await; + { + let mut st = shared + .state + .lock() + .unwrap_or_else(|e| e.into_inner()); + st.finish_job(&track_id); + } + shared.ping_worker(); + + let result = match download_result { + Ok((bytes, fetch_ms)) => { + crate::app_deprintln!( + "[analysis] backfill worker={}/{}: fetched track_id={} fetch_ms={}", + worker_slot, + max, + track_id, + fetch_ms + ); + let priority = analysis_backfill_resolve_priority(&app, &server_id, &track_id, None); + match enqueue_track_analysis_with_fetch( + &app, + &server_id, + &track_id, + &bytes, + priority, + fetch_ms, + ) + .await + { + Ok(outcome) => { + Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete)) + } + Err(e) => Err(e), + } + } + Err(e) => Err(e), + }; + match &result { Ok(has_loudness) => crate::app_deprintln!( - "[analysis] backfill ready: {} (has_loudness={})", + "[analysis] backfill worker={}/{}: ready track_id={} has_loudness={}", + worker_slot, + max, track_id, has_loudness ), - Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e), + Err(e) => crate::app_eprintln!( + "[analysis] backfill worker={}/{}: failed track_id={}: {}", + worker_slot, + max, + track_id, + e + ), } - let mut st = shared - .state - .lock() - .unwrap_or_else(|e| e.into_inner()); - st.finish_job(&track_id); - } + }); + } +} + +pub fn analysis_set_pipeline_parallelism(workers: usize) { + let workers = clamp_pipeline_parallelism(workers); + REQUESTED_PIPELINE_PARALLELISM.store(workers, Ordering::Relaxed); + if let Some(shared) = ANALYSIS_BACKFILL.get() { + shared + .max_parallel + .store(workers, Ordering::Relaxed); + shared.ping_worker(); + } + if let Some(shared) = ANALYSIS_CPU_SEED.get() { + shared + .max_parallel + .store(workers, Ordering::Relaxed); + shared.ping_worker(); + } +} + +pub fn analysis_backfill_queue_stats() -> (usize, usize, Option) { + if let Some(shared) = ANALYSIS_BACKFILL.get() { + let st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + let in_progress_count = st.in_progress.len(); + let first_in_progress = st.in_progress.keys().next().cloned(); + (st.queued_len(), in_progress_count, first_in_progress) + } else { + (0, 0, None) + } +} + +pub fn analysis_track_in_cpu_pipeline(track_id: &str) -> bool { + let tid = track_id.trim(); + if tid.is_empty() { + return false; + } + let Some(shared) = ANALYSIS_CPU_SEED.get() else { + return false; + }; + let st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + if st.running.contains_key(tid) { + return true; + } + st.locate_queued(tid).is_some() +} + +pub fn analysis_pipeline_queue_stats() -> AnalysisPipelineQueueStatsDto { + let pipeline_workers = ANALYSIS_BACKFILL + .get() + .map(|shared| shared.max_parallel()) + .or_else(|| ANALYSIS_CPU_SEED.get().map(|shared| shared.max_parallel())) + .unwrap_or(ANALYSIS_PIPELINE_PARALLELISM_DEFAULT) as u32; + + let (http_tiers, http_active_tiers) = if let Some(shared) = ANALYSIS_BACKFILL.get() { + let st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + (st.queued_tier_counts(), st.in_progress_tier_counts()) + } else { + (AnalysisTierCounts::default(), AnalysisTierCounts::default()) + }; + + let (cpu_tiers, cpu_active_tiers) = if let Some(shared) = ANALYSIS_CPU_SEED.get() { + let st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + (st.queued_tier_counts(), st.running_tier_counts()) + } else { + (AnalysisTierCounts::default(), AnalysisTierCounts::default()) + }; + + AnalysisPipelineQueueStatsDto { + pipeline_workers, + http_queued: http_tiers.total(), + http_queued_high: http_tiers.high, + http_queued_middle: http_tiers.middle, + http_queued_low: http_tiers.low, + http_download_active: http_active_tiers.total(), + http_download_active_high: http_active_tiers.high, + http_download_active_middle: http_active_tiers.middle, + http_download_active_low: http_active_tiers.low, + cpu_queued: cpu_tiers.total(), + cpu_queued_high: cpu_tiers.high, + cpu_queued_middle: cpu_tiers.middle, + cpu_queued_low: cpu_tiers.low, + cpu_decode_active: cpu_active_tiers.total(), + cpu_decode_active_high: cpu_active_tiers.high, + cpu_decode_active_middle: cpu_active_tiers.middle, + cpu_decode_active_low: cpu_active_tiers.low, } } @@ -309,78 +712,170 @@ pub fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str .is_some_and(|p| p.is_track_currently_playing(track_id)) } -// ─── Full-track waveform + loudness: single CPU worker (mirrors HTTP backfill queue) ─ -// One `spawn_blocking` decode at a time; current playback is high-priority (front + reorder). -// Same `track_id` queued again merges waiters onto one job; while decode runs, same-id -// submitters attach to `running` followers so they all get the same outcome. +pub fn analysis_backfill_resolve_priority( + app: &tauri::AppHandle, + server_id: &str, + track_id: &str, + explicit: Option, +) -> AnalysisBackfillPriority { + if let Some(priority) = explicit { + return priority; + } + if analysis_backfill_is_current_track(app, track_id) { + return AnalysisBackfillPriority::High; + } + if app + .try_state::() + .is_some_and(|h| h.is_middle_priority(server_id, track_id)) + { + return AnalysisBackfillPriority::Middle; + } + AnalysisBackfillPriority::Low +} + +// ─── Full-track waveform + loudness: CPU seed queue (parallel decode workers) ─ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnalysisCpuSeedEnqueueKind { - NewBack, - NewFront, - ReorderedFront, + NewLow, + NewMiddle, + NewHigh, + ReorderedHigher, RunningFollower, MergedQueued, } -type SeedDoneSender = - tokio::sync::oneshot::Sender>; -type RunningSeedJob = (String, Arc>>); +type SeedDoneSender = tokio::sync::oneshot::Sender< + Result<(analysis_cache::SeedFromBytesOutcome, AnalysisSeedTimings), String>, +>; +type SeedDoneReceiver = tokio::sync::oneshot::Receiver< + Result<(analysis_cache::SeedFromBytesOutcome, AnalysisSeedTimings), String>, +>; +type RunningSeedJob = 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. + /// Playback server scope for the write key. server_id: String, track_id: String, bytes: Vec, waiters: Vec, + /// HTTP download time when this job came from the backfill worker. + fetch_ms: u64, + priority: AnalysisBackfillPriority, } #[derive(Default)] struct AnalysisCpuSeedQueueState { - deque: VecDeque, - /// Decode in progress — same-id callers wait here for the same outcome. - running: Option, + high: VecDeque, + middle: VecDeque, + low: VecDeque, + /// Decodes in progress — same-id callers wait on the matching entry. + running: HashMap, + running_tiers: HashMap, } impl AnalysisCpuSeedQueueState { + fn queued_len(&self) -> usize { + self.high.len() + self.middle.len() + self.low.len() + } + + fn queued_tier_counts(&self) -> AnalysisTierCounts { + AnalysisTierCounts { + high: self.high.len(), + middle: self.middle.len(), + low: self.low.len(), + } + } + + fn running_tier_counts(&self) -> AnalysisTierCounts { + let mut counts = AnalysisTierCounts::default(); + for tier in self.running_tiers.values() { + match tier { + AnalysisBackfillPriority::High => counts.high += 1, + AnalysisBackfillPriority::Middle => counts.middle += 1, + AnalysisBackfillPriority::Low => counts.low += 1, + } + } + counts + } + + fn tier_deque(&self, tier: AnalysisBackfillPriority) -> &VecDeque { + match tier { + AnalysisBackfillPriority::High => &self.high, + AnalysisBackfillPriority::Middle => &self.middle, + AnalysisBackfillPriority::Low => &self.low, + } + } + + fn tier_deque_mut(&mut self, tier: AnalysisBackfillPriority) -> &mut VecDeque { + match tier { + AnalysisBackfillPriority::High => &mut self.high, + AnalysisBackfillPriority::Middle => &mut self.middle, + AnalysisBackfillPriority::Low => &mut self.low, + } + } + + fn locate_queued(&self, tid: &str) -> Option<(AnalysisBackfillPriority, usize)> { + for tier in [ + AnalysisBackfillPriority::High, + AnalysisBackfillPriority::Middle, + AnalysisBackfillPriority::Low, + ] { + if let Some(pos) = self + .tier_deque(tier) + .iter() + .position(|j| j.track_id == tid) + { + return Some((tier, pos)); + } + } + None + } + + fn push_new(&mut self, priority: AnalysisBackfillPriority, job: AnalysisCpuSeedJob) { + match priority { + AnalysisBackfillPriority::High => self.high.push_front(job), + AnalysisBackfillPriority::Middle => self.middle.push_back(job), + AnalysisBackfillPriority::Low => self.low.push_back(job), + } + } + fn enqueue( &mut self, server_id: String, track_id: String, bytes: Vec, - high_priority: bool, + priority: AnalysisBackfillPriority, + fetch_ms: u64, ) -> ( AnalysisCpuSeedEnqueueKind, - tokio::sync::oneshot::Receiver>, + SeedDoneReceiver, ) { let (done_tx, done_rx) = tokio::sync::oneshot::channel(); let tid = track_id.as_str(); - if let Some((rtid, followers)) = &self.running { - if rtid == tid { - followers - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(done_tx); - return (AnalysisCpuSeedEnqueueKind::RunningFollower, done_rx); - } + if let Some(followers) = self.running.get(tid) { + followers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(done_tx); + return (AnalysisCpuSeedEnqueueKind::RunningFollower, done_rx); } - 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. + if let Some((existing_tier, pos)) = self.locate_queued(tid) { + let mut job = self.tier_deque_mut(existing_tier).remove(pos).unwrap(); job.server_id = server_id; job.bytes = bytes; + job.fetch_ms = fetch_ms; job.waiters.push(done_tx); - let kind = if high_priority { - self.deque.push_front(job); - AnalysisCpuSeedEnqueueKind::ReorderedFront - } else { - self.deque.push_back(job); - AnalysisCpuSeedEnqueueKind::MergedQueued - }; - return (kind, done_rx); + if priority > existing_tier { + job.priority = priority; + self.push_new(priority, job); + return (AnalysisCpuSeedEnqueueKind::ReorderedHigher, done_rx); + } + job.priority = existing_tier; + self.tier_deque_mut(existing_tier).push_back(job); + return (AnalysisCpuSeedEnqueueKind::MergedQueued, done_rx); } let job = AnalysisCpuSeedJob { @@ -388,48 +883,78 @@ impl AnalysisCpuSeedQueueState { track_id: track_id.clone(), bytes, waiters: vec![done_tx], + fetch_ms, + priority, }; - let kind = if high_priority { - self.deque.push_front(job); - AnalysisCpuSeedEnqueueKind::NewFront - } else { - self.deque.push_back(job); - AnalysisCpuSeedEnqueueKind::NewBack + let kind = match priority { + AnalysisBackfillPriority::High => AnalysisCpuSeedEnqueueKind::NewHigh, + AnalysisBackfillPriority::Middle => AnalysisCpuSeedEnqueueKind::NewMiddle, + AnalysisBackfillPriority::Low => AnalysisCpuSeedEnqueueKind::NewLow, }; + self.push_new(priority, job); (kind, done_rx) } - fn prune_queued_not_in(&mut self, keep_track_ids: &HashSet<&str>) -> (usize, usize) { - let mut kept = VecDeque::with_capacity(self.deque.len()); + fn prune_queued_not_in( + &mut self, + keep_track_ids: &HashSet<&str>, + server_id: Option<&str>, + ) -> (usize, usize) { let mut removed_jobs = 0usize; let mut removed_waiters = 0usize; - while let Some(job) = self.deque.pop_front() { - if keep_track_ids.contains(job.track_id.as_str()) { - kept.push_back(job); - continue; - } - removed_jobs += 1; - removed_waiters += job.waiters.len(); - for tx in job.waiters { - let _ = tx.send(Err( - "cpu-seed pruned: track no longer in playback queue".to_string(), - )); + for tier in [ + AnalysisBackfillPriority::High, + AnalysisBackfillPriority::Middle, + AnalysisBackfillPriority::Low, + ] { + let mut kept = VecDeque::with_capacity(self.tier_deque(tier).len()); + while let Some(job) = self.tier_deque_mut(tier).pop_front() { + let scoped = server_id.is_some_and(|sid| { + job.server_id.is_empty() || job.server_id == sid + }); + if server_id.is_some() && !scoped { + kept.push_back(job); + continue; + } + if keep_track_ids.contains(job.track_id.as_str()) { + kept.push_back(job); + continue; + } + removed_jobs += 1; + removed_waiters += job.waiters.len(); + for tx in job.waiters { + let _ = tx.send(Err( + "cpu-seed pruned: track no longer in playback queue".to_string(), + )); + } } + *self.tier_deque_mut(tier) = kept; } - self.deque = kept; (removed_jobs, removed_waiters) } + + fn try_pop_next(&mut self) -> Option { + self.high + .pop_front() + .or_else(|| self.middle.pop_front()) + .or_else(|| self.low.pop_front()) + } } struct AnalysisCpuSeedShared { state: Mutex, wake_tx: tokio::sync::mpsc::UnboundedSender<()>, + max_parallel: AtomicUsize, } impl AnalysisCpuSeedShared { fn ping_worker(&self) { let _ = self.wake_tx.send(()); } + + fn max_parallel(&self) -> usize { + clamp_pipeline_parallelism(self.max_parallel.load(Ordering::Relaxed)) + } } static ANALYSIS_CPU_SEED: OnceLock> = OnceLock::new(); @@ -441,10 +966,11 @@ fn analysis_cpu_seed_shared(app: &tauri::AppHandle) -> Arc ( - Some(tid.as_str()), - fl.lock().map(|g| g.len()).unwrap_or(0), - ), - None => (None, 0usize), - }; + let queued_jobs = st.queued_len(); + let decoding_count = st.running.len(); + let tiers = st.queued_tier_counts(); format!( - "cpu_seed={{queued_jobs:{} pending_channels_in_queue:{} decoding_tid:{:?} extra_waiters_same_id:{}}}", + "cpu_seed={{queued_jobs:{} tiers=({},{},{}) decoding_active:{}}}", queued_jobs, - pending_in_queued_jobs, - decoding_tid, - decoding_extra_waiters + tiers.high, + tiers.middle, + tiers.low, + decoding_count, ) } else { "cpu_seed={{not_started}}".to_string() @@ -507,23 +1031,50 @@ async fn analysis_cpu_seed_worker_loop( if wake_rx.recv().await.is_none() { break; } - loop { - let (job, followers) = { - let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); - let Some(j) = st.deque.pop_front() else { - break; - }; - let fl = Arc::new(Mutex::new(Vec::new())); - st.running = Some((j.track_id.clone(), fl.clone())); - (j, fl) - }; - let tid_log = job.track_id.clone(); - let app2 = app.clone(); + spawn_cpu_seed_slots(&app, &shared).await; + } +} + +async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc) { + loop { + let max = shared.max_parallel(); + let job_bundle = { + let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + if st.running.len() >= max { + None + } else { + st.try_pop_next().map(|j| { + let followers = Arc::new(Mutex::new(Vec::new())); + let job_priority = j.priority; + st.running + .insert(j.track_id.clone(), followers.clone()); + st.running_tiers + .insert(j.track_id.clone(), job_priority); + let worker_slot = st.running.len(); + (j, followers, worker_slot) + }) + } + }; + let Some((job, followers, worker_slot)) = job_bundle else { + break; + }; + let tid_log = job.track_id.clone(); + let fetch_ms = job.fetch_ms; + crate::app_deprintln!( + "[analysis] cpu-seed worker={}/{}: start track_id={}", + worker_slot, + max, + tid_log + ); + let app_for_decode = app.clone(); + let app_for_events = app.clone(); + let shared = shared.clone(); + tauri::async_runtime::spawn(async move { 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, &sid, &tid, &bytes) + let seed_result = tokio::task::spawn_blocking(move || { + analysis_cache::seed_from_bytes_execute(&app_for_decode, &sid, &tid, &bytes) }) .await .unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}"))); @@ -534,23 +1085,56 @@ async fn analysis_cpu_seed_worker_loop( .drain(..) .collect::>(); for tx in job.waiters { - let _ = tx.send(outcome.clone()); + let _ = tx.send(seed_result.clone()); } for tx in extra.drain(..) { - let _ = tx.send(outcome.clone()); + let _ = tx.send(seed_result.clone()); } { let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); - st.running = None; + st.running.remove(&tid_log); + st.running_tiers.remove(&tid_log); } - let ok = outcome.as_ref().map(|o| *o == analysis_cache::SeedFromBytesOutcome::Upserted).unwrap_or(false); - crate::app_deprintln!( - "[analysis] cpu-seed worker: done track_id={} upserted={}", - tid_log, - ok - ); - } + + match &seed_result { + Ok((outcome, timings)) => { + let ok = *outcome == analysis_cache::SeedFromBytesOutcome::Upserted; + emit_analysis_track_perf( + &app_for_events, + &tid_log, + fetch_ms, + timings.seed_ms, + timings.bpm_ms, + ); + crate::app_deprintln!( + "[analysis] cpu-seed worker={}/{}: done track_id={} upserted={}", + worker_slot, + max, + tid_log, + ok + ); + if ok { + let _ = app_for_events.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: tid_log.clone(), + is_partial: false, + }, + ); + } + } + Err(e) => { + crate::app_eprintln!( + "[analysis] cpu-seed worker={}/{}: failed track_id={}: {e}", + worker_slot, + max, + tid_log + ); + } + } + shared.ping_worker(); + }); } } @@ -563,13 +1147,14 @@ async fn analysis_cpu_seed_worker_loop( /// queue may not have been initialized yet — those slots return 0. pub fn prune_analysis_queues( keep_track_ids: &HashSet<&str>, + server_id: Option<&str>, ) -> Result<(usize, usize, usize), String> { let http_removed = if let Some(shared) = ANALYSIS_BACKFILL.get() { let mut st = shared .state .lock() .map_err(|_| "analysis backfill lock poisoned".to_string())?; - st.prune_queued_not_in(keep_track_ids) + st.prune_queued_not_in(keep_track_ids, server_id) } else { 0 }; @@ -579,7 +1164,7 @@ pub fn prune_analysis_queues( .state .lock() .map_err(|_| "analysis cpu-seed lock poisoned".to_string())?; - st.prune_queued_not_in(keep_track_ids) + st.prune_queued_not_in(keep_track_ids, server_id) } else { (0, 0) }; @@ -587,8 +1172,8 @@ pub fn prune_analysis_queues( Ok((http_removed, cpu_removed_jobs, cpu_removed_waiters)) } -/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors -/// HTTP backfill head insertion for the currently playing track. +/// Submit full-buffer analysis; serializes with other producers. Priority mirrors +/// HTTP backfill tier ordering (high → middle → low). /// /// Emits `analysis:waveform-updated` when analysis **wrote** new waveform data (`Upserted`). /// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not @@ -598,30 +1183,22 @@ pub async fn submit_analysis_cpu_seed( server_id: String, track_id: String, bytes: Vec, - high_priority: bool, + priority: AnalysisBackfillPriority, + fetch_ms: u64, ) -> Result { 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(server_id, track_id.clone(), bytes, high_priority); - crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} high_priority={high_priority}"); + let (kind, rx) = st.enqueue(server_id, track_id.clone(), bytes, priority, fetch_ms); + crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} priority={priority:?}"); drop(st); shared.ping_worker(); rx }; - let outcome = match rx.await { + let (outcome, _timings) = match rx.await { Ok(res) => res?, Err(_) => return Err("cpu-seed: result channel dropped".to_string()), }; - if matches!(outcome, analysis_cache::SeedFromBytesOutcome::Upserted) { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { - track_id: track_id.clone(), - is_partial: false, - }, - ); - } Ok(outcome) } @@ -632,192 +1209,338 @@ mod tests { // ── AnalysisBackfillQueueState ──────────────────────────────────────────── #[test] - fn backfill_default_state_has_empty_deque_and_no_in_progress() { + fn backfill_default_state_has_empty_queues_and_no_in_progress() { let s = AnalysisBackfillQueueState::default(); - assert!(s.deque.is_empty()); - assert!(s.in_progress.is_none()); + assert_eq!(s.queued_len(), 0); + assert!(s.in_progress.is_empty()); } #[test] - fn backfill_is_reserved_checks_both_deque_and_in_progress() { + fn backfill_is_reserved_checks_all_tiers_and_in_progress() { let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("queued".into(), "u".into(), String::new())); - s.in_progress = Some("active".into()); + s.enqueue( + String::new(), + "queued".into(), + "u".into(), + AnalysisBackfillPriority::Middle, + ); + s.in_progress.insert("active".into(), AnalysisBackfillPriority::Low); assert!(s.is_reserved("queued")); assert!(s.is_reserved("active")); assert!(!s.is_reserved("other")); } #[test] - fn backfill_try_pop_next_promotes_head_to_in_progress() { + fn backfill_try_pop_next_drains_high_then_middle_then_low() { let mut s = AnalysisBackfillQueueState::default(); - 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")); - assert_eq!(s.deque.len(), 1); + s.enqueue(String::new(), "low".into(), "u".into(), AnalysisBackfillPriority::Low); + s.enqueue(String::new(), "mid".into(), "u".into(), AnalysisBackfillPriority::Middle); + s.enqueue(String::new(), "hi".into(), "u".into(), AnalysisBackfillPriority::High); + assert_eq!(s.try_pop_next(4).unwrap().0, "hi"); + assert_eq!(s.try_pop_next(4).unwrap().0, "mid"); + assert_eq!(s.try_pop_next(4).unwrap().0, "low"); } #[test] - fn backfill_try_pop_next_returns_none_for_empty_deque() { + fn backfill_enqueue_low_priority_appends_to_low_tier() { let mut s = AnalysisBackfillQueueState::default(); - assert!(s.try_pop_next().is_none()); - assert!(s.in_progress.is_none()); + s.enqueue( + String::new(), + "first".into(), + "u".into(), + AnalysisBackfillPriority::High, + ); + let kind = s.enqueue( + String::new(), + "second".into(), + "u2".into(), + AnalysisBackfillPriority::Low, + ); + assert_eq!(kind, AnalysisBackfillEnqueueKind::NewLow); + assert_eq!(s.try_pop_next(4).unwrap().0, "first"); + assert_eq!(s.try_pop_next(4).unwrap().0, "second"); } #[test] - fn backfill_finish_job_only_clears_when_id_matches() { - let mut s = AnalysisBackfillQueueState { - in_progress: Some("active".into()), - ..Default::default() - }; - s.finish_job("other"); - assert_eq!(s.in_progress.as_deref(), Some("active")); - s.finish_job("active"); - assert!(s.in_progress.is_none()); + fn backfill_enqueue_high_priority_pushes_to_high_tier() { + let mut s = AnalysisBackfillQueueState::default(); + s.enqueue( + String::new(), + "old".into(), + "u".into(), + AnalysisBackfillPriority::Low, + ); + let kind = s.enqueue( + String::new(), + "hot".into(), + "u2".into(), + AnalysisBackfillPriority::High, + ); + assert_eq!(kind, AnalysisBackfillEnqueueKind::NewHigh); + assert_eq!(s.try_pop_next(4).unwrap().0, "hot"); } #[test] - fn backfill_enqueue_low_priority_appends_to_back() { + fn backfill_enqueue_middle_priority_appends_to_middle_tier() { let mut s = AnalysisBackfillQueueState::default(); - 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"); + s.enqueue( + String::new(), + "old".into(), + "u".into(), + AnalysisBackfillPriority::Low, + ); + let kind = s.enqueue( + String::new(), + "next".into(), + "u2".into(), + AnalysisBackfillPriority::Middle, + ); + assert_eq!(kind, AnalysisBackfillEnqueueKind::NewMiddle); + assert_eq!(s.try_pop_next(4).unwrap().0, "next"); + assert_eq!(s.try_pop_next(4).unwrap().0, "old"); } #[test] - fn backfill_enqueue_high_priority_pushes_to_front() { + fn backfill_enqueue_returns_duplicate_skipped_for_same_tier_dup() { let mut s = AnalysisBackfillQueueState::default(); - 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"); - } - - #[test] - fn backfill_enqueue_returns_duplicate_skipped_for_low_prio_dup() { - let mut s = AnalysisBackfillQueueState::default(); - s.deque.push_back(("dup".into(), "u".into(), String::new())); - let kind = s.enqueue(String::new(), "dup".into(), "u2".into(), false); + s.enqueue( + String::new(), + "dup".into(), + "u".into(), + AnalysisBackfillPriority::Low, + ); + let kind = s.enqueue( + String::new(), + "dup".into(), + "u2".into(), + AnalysisBackfillPriority::Low, + ); assert_eq!(kind, AnalysisBackfillEnqueueKind::DuplicateSkipped); - assert_eq!(s.deque.len(), 1); + assert_eq!(s.queued_len(), 1); + } + + #[test] + fn backfill_enqueue_upgrades_low_to_middle() { + let mut s = AnalysisBackfillQueueState::default(); + s.enqueue( + String::new(), + "dup".into(), + "old_url".into(), + AnalysisBackfillPriority::Low, + ); + let kind = s.enqueue( + "server-1".into(), + "dup".into(), + "fresh_url".into(), + AnalysisBackfillPriority::Middle, + ); + assert_eq!(kind, AnalysisBackfillEnqueueKind::ReorderedHigher); + let job = s.try_pop_next(4).unwrap(); + assert_eq!(job.0, "dup"); + assert_eq!(job.1, "fresh_url"); + assert_eq!(job.2, "server-1"); + assert_eq!(s.queued_len(), 0); } #[test] fn backfill_enqueue_returns_running_skipped_for_high_prio_active_track() { let mut s = AnalysisBackfillQueueState { - in_progress: Some("active".into()), + in_progress: HashMap::from([( + String::from("active"), + AnalysisBackfillPriority::Low, + )]), ..Default::default() }; - let kind = s.enqueue(String::new(), "active".into(), "u".into(), true); + let kind = s.enqueue( + String::new(), + "active".into(), + "u".into(), + AnalysisBackfillPriority::High, + ); assert_eq!(kind, AnalysisBackfillEnqueueKind::RunningSkipped); } #[test] - fn backfill_enqueue_high_prio_dup_in_deque_reorders_to_front_with_new_url() { + fn backfill_try_pop_next_respects_max_concurrent() { let mut s = AnalysisBackfillQueueState::default(); - 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(), "server-1".to_string()) - ); - assert_eq!(s.deque.iter().filter(|(t, _, _)| t == "dup").count(), 1, "no duplicate left behind"); + s.enqueue(String::new(), "a".into(), "u".into(), AnalysisBackfillPriority::Low); + s.enqueue(String::new(), "b".into(), "u".into(), AnalysisBackfillPriority::Low); + s.in_progress.insert("active".into(), AnalysisBackfillPriority::Low); + assert!(s.try_pop_next(1).is_none()); + assert_eq!(s.try_pop_next(2).unwrap().0, "a"); } #[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(), String::new())); + s.enqueue(String::new(), tid.into(), "u".into(), AnalysisBackfillPriority::Low); } let keep: HashSet<&str> = ["a", "c"].iter().copied().collect(); - let removed = s.prune_queued_not_in(&keep); + let removed = s.prune_queued_not_in(&keep, None); assert_eq!(removed, 2); - let remaining: Vec<&str> = s.deque.iter().map(|(t, _, _)| t.as_str()).collect(); - assert_eq!(remaining, vec!["a", "c"]); + assert_eq!(s.try_pop_next(4).unwrap().0, "a"); + assert_eq!(s.try_pop_next(4).unwrap().0, "c"); } // ── AnalysisCpuSeedQueueState ───────────────────────────────────────────── #[test] - fn cpu_seed_enqueue_low_prio_appends_to_back() { + fn cpu_seed_enqueue_low_prio_appends_to_low_tier() { let mut s = AnalysisCpuSeedQueueState::default(); - let (kind, _rx) = s.enqueue(String::new(), "a".into(), vec![], false); - assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewBack); - assert_eq!(s.deque.len(), 1); + let (kind, _rx) = s.enqueue( + String::new(), + "a".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewLow); + assert_eq!(s.queued_len(), 1); } #[test] - fn cpu_seed_enqueue_high_prio_pushes_to_front() { + fn cpu_seed_enqueue_high_prio_pushes_to_high_tier() { let mut s = AnalysisCpuSeedQueueState::default(); - 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"); + let (_, _r1) = s.enqueue( + String::new(), + "first".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + let (kind, _r2) = s.enqueue( + String::new(), + "hot".into(), + vec![], + AnalysisBackfillPriority::High, + 0, + ); + assert_eq!(kind, AnalysisCpuSeedEnqueueKind::NewHigh); + assert_eq!(s.try_pop_next().unwrap().track_id, "hot"); } #[test] fn cpu_seed_enqueue_existing_low_prio_merges_at_back() { let mut s = AnalysisCpuSeedQueueState::default(); - 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); + let (_, _r1) = s.enqueue( + "server-a".into(), + "dup".into(), + vec![1, 2, 3], + AnalysisBackfillPriority::Low, + 0, + ); + let (kind, _r2) = s.enqueue( + "server-b".into(), + "dup".into(), + vec![4, 5, 6], + AnalysisBackfillPriority::Low, + 0, + ); 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"); + assert_eq!(s.queued_len(), 1); + let job = s.try_pop_next().unwrap(); + assert_eq!(job.bytes, vec![4, 5, 6], "fresh bytes overwrite"); + assert_eq!(job.server_id, "server-b", "latest server scope wins on merge"); + assert_eq!(job.waiters.len(), 2, "both waiters attached"); } #[test] - fn cpu_seed_enqueue_existing_high_prio_reorders_to_front() { + fn cpu_seed_enqueue_existing_low_prio_upgrades_to_high() { let mut s = AnalysisCpuSeedQueueState::default(); - 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"); + let (_, _r1) = s.enqueue( + String::new(), + "first".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + let (_, _r2) = s.enqueue( + String::new(), + "dup".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + let (kind, _r3) = s.enqueue( + String::new(), + "dup".into(), + vec![], + AnalysisBackfillPriority::High, + 0, + ); + assert_eq!(kind, AnalysisCpuSeedEnqueueKind::ReorderedHigher); + assert_eq!(s.try_pop_next().unwrap().track_id, "dup"); } #[test] fn cpu_seed_enqueue_running_id_attaches_as_follower() { 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(String::new(), "active".into(), vec![], false); + s.running.insert("active".into(), followers.clone()); + let (kind, _rx) = s.enqueue( + String::new(), + "active".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); 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"); + assert_eq!(s.queued_len(), 0, "follower does not occupy a queue slot"); } #[test] fn cpu_seed_prune_returns_removed_jobs_and_waiter_count() { let mut s = AnalysisCpuSeedQueueState::default(); - 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 (_, _r1) = s.enqueue( + String::new(), + "a".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + let (_, _r2) = s.enqueue( + String::new(), + "b".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + let (_, _r3) = s.enqueue( + String::new(), + "a".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); + let (_, _r4) = s.enqueue( + String::new(), + "c".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); let keep: HashSet<&str> = ["a"].iter().copied().collect(); - let (removed_jobs, removed_waiters) = s.prune_queued_not_in(&keep); + let (removed_jobs, removed_waiters) = s.prune_queued_not_in(&keep, None); assert_eq!(removed_jobs, 2, "b and c removed"); assert_eq!(removed_waiters, 2, "one waiter on b + one on c"); - let remaining: Vec<&str> = s.deque.iter().map(|j| j.track_id.as_str()).collect(); - assert_eq!(remaining, vec!["a"]); + assert_eq!(s.try_pop_next().unwrap().track_id, "a"); } #[test] fn cpu_seed_prune_sends_err_to_dropped_waiters() { let mut s = AnalysisCpuSeedQueueState::default(); - let (_, rx) = s.enqueue(String::new(), "doomed".into(), vec![], false); + let (_, rx) = s.enqueue( + String::new(), + "doomed".into(), + vec![], + AnalysisBackfillPriority::Low, + 0, + ); let keep: HashSet<&str> = HashSet::new(); - let _ = s.prune_queued_not_in(&keep); - // After pruning, the waiter receives the cancellation Err. + let _ = s.prune_queued_not_in(&keep, None); let result = rx.blocking_recv().expect("sender side should have closed cleanly"); assert!(result.is_err(), "pruned job must yield Err, got {result:?}"); } diff --git a/src-tauri/crates/psysonic-analysis/src/commands.rs b/src-tauri/crates/psysonic-analysis/src/commands.rs index e4e3d38b..7071c4c4 100644 --- a/src-tauri/crates/psysonic-analysis/src/commands.rs +++ b/src-tauri/crates/psysonic-analysis/src/commands.rs @@ -11,8 +11,10 @@ use psysonic_core::ports::PlaybackQueryHandle; use crate::analysis_cache; use crate::analysis_runtime::{ - analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues, - track_analysis_needs_work, AnalysisBackfillEnqueueKind, + analysis_backfill_queue_stats, analysis_backfill_resolve_priority, analysis_backfill_shared, + analysis_pipeline_queue_stats, prune_analysis_queues, track_analysis_needs_work, + AnalysisBackfillEnqueueKind, + AnalysisBackfillPriority, PlaybackPriorityHints, }; #[derive(serde::Serialize)] @@ -49,11 +51,35 @@ pub struct LoudnessCachePayload { pub updated_at: i64, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisDeleteServerReportDto { + pub analysis_tracks: u64, + pub waveforms: u64, + pub loudness: u64, +} + +impl From for AnalysisDeleteServerReportDto { + fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self { + Self { + analysis_tracks: value.analysis_tracks, + waveforms: value.waveforms, + loudness: value.loudness, + } + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisServerKeyMigrationDto { + pub legacy_id: String, + pub index_key: String, +} + /// 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. +/// md5_16kb)` key. 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, @@ -65,26 +91,13 @@ pub fn get_waveform_payload( track_id: track_id.to_string(), md5_16kb: md5_16kb.to_string(), }; - 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) + Ok(cache + .get_waveform(&exact)? + .map(WaveformCachePayload::from)) } /// 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`]. +/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`]. pub fn get_waveform_payload_for_track( cache: &analysis_cache::AnalysisCache, server_id: &str, @@ -96,7 +109,7 @@ pub fn get_waveform_payload_for_track( } /// AppHandle-free helper: looks up the latest loudness row for `(server_id, -/// track_id)` (legacy fallback + lazy re-tag) and recomputes `recommended_gain_db` +/// 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. pub fn get_loudness_payload_for_track( @@ -202,18 +215,69 @@ pub fn analysis_delete_all_waveforms( cache.delete_all_waveforms() } +#[tauri::command] +pub fn analysis_delete_all_for_server( + server_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + if server_id.trim().is_empty() { + return Err("server_id required".to_string()); + } + let report = cache.delete_all_for_server(&server_id)?; + Ok(report.into()) +} + +#[tauri::command] +pub fn analysis_migrate_server_index_keys( + mappings: Vec, + _cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result<(), String> { + for mapping in mappings { + let _ = (mapping.legacy_id, mapping.index_key); + } + Ok(()) +} + #[tauri::command] pub fn analysis_enqueue_seed_from_url( track_id: String, url: String, force: Option, server_id: Option, + priority: 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 server_id = if let Ok(parsed) = reqwest::Url::parse(&url) { + if parsed.scheme() == "http" || parsed.scheme() == "https" { + let host = parsed.host_str().unwrap_or_default(); + let mut base_path = parsed.path().to_string(); + if let Some(idx) = base_path.find("/rest") { + base_path.truncate(idx); + } + while base_path.ends_with('/') { + base_path.pop(); + } + if host.is_empty() { + server_id.unwrap_or_default() + } else { + let mut base = host.to_string(); + if let Some(port) = parsed.port() { + base.push_str(&format!(":{port}")); + } + if !base_path.is_empty() { + base.push_str(&base_path); + } + base + } + } else { + server_id.unwrap_or_default() + } + } else { + server_id.unwrap_or_default() + }; let force = force.unwrap_or(false); if !force { if let Some(playback) = app.try_state::() { @@ -251,29 +315,32 @@ pub fn analysis_enqueue_seed_from_url( } } let tid_log = track_id.clone(); - let high_priority = analysis_backfill_is_current_track(&app, &track_id); + let explicit = AnalysisBackfillPriority::from_optional_str(priority.as_deref()); + let resolved = + analysis_backfill_resolve_priority(&app, &server_id, &track_id, explicit); let shared = analysis_backfill_shared(&app); let kind = { let mut st = shared .state .lock() .map_err(|_| "analysis backfill lock poisoned".to_string())?; - st.enqueue(server_id, track_id, url, high_priority) + st.enqueue(server_id, track_id, url, resolved) }; match kind { - AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => { + AnalysisBackfillEnqueueKind::NewLow + | AnalysisBackfillEnqueueKind::NewMiddle + | AnalysisBackfillEnqueueKind::NewHigh => { shared.ping_worker(); crate::app_deprintln!( - "[analysis] backfill enqueued: track_id={} position={}", + "[analysis] backfill enqueued: track_id={} priority={resolved:?}", tid_log, - if high_priority { "front" } else { "back" } ); } - AnalysisBackfillEnqueueKind::ReorderedFront => { + AnalysisBackfillEnqueueKind::ReorderedHigher => { shared.ping_worker(); crate::app_deprintln!( - "[analysis] backfill bumped to front (current track) track_id={}", - tid_log + "[analysis] backfill bumped tier track_id={} priority={resolved:?}", + tid_log, ); } AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {} @@ -281,6 +348,55 @@ pub fn analysis_enqueue_seed_from_url( Ok(()) } +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisPriorityHintDto { + pub server_id: String, + pub track_id: String, +} + +#[tauri::command] +pub fn analysis_set_playback_priority_hints( + middle_track_refs: Vec, + hints: tauri::State<'_, PlaybackPriorityHints>, +) -> Result<(), String> { + let pairs = middle_track_refs + .into_iter() + .map(|r| (r.server_id, r.track_id)); + hints.set_middle_track_ids(pairs); + Ok(()) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisBackfillQueueStatsDto { + pub queued: usize, + pub in_progress_count: usize, + pub in_progress_track_id: Option, +} + +#[tauri::command] +pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> { + crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize); + Ok(()) +} + +#[tauri::command] +pub fn analysis_get_pipeline_queue_stats() -> Result { + Ok(analysis_pipeline_queue_stats()) +} + +#[tauri::command] +pub fn analysis_get_backfill_queue_stats() -> Result { + let (queued, in_progress_count, in_progress_track_id) = + analysis_backfill_queue_stats(); + Ok(AnalysisBackfillQueueStatsDto { + queued, + in_progress_count, + in_progress_track_id, + }) +} + #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AnalysisPrunePendingResult { @@ -296,6 +412,7 @@ pub struct AnalysisPrunePendingResult { #[tauri::command] pub fn analysis_prune_pending_to_track_ids( track_ids: Vec, + server_id: String, ) -> Result { let mut normalized: Vec = Vec::with_capacity(track_ids.len()); let mut seen = HashSet::new(); @@ -310,8 +427,10 @@ pub fn analysis_prune_pending_to_track_ids( } let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect(); + let server_id = server_id.trim().to_string(); + let server_filter = if server_id.is_empty() { None } else { Some(server_id.as_str()) }; let (http_removed, cpu_removed_jobs, cpu_removed_waiters) = - prune_analysis_queues(&keep_track_ids)?; + prune_analysis_queues(&keep_track_ids, server_filter)?; if http_removed > 0 || cpu_removed_jobs > 0 { crate::app_deprintln!( @@ -340,7 +459,7 @@ mod tests { fn key(track_id: &str, md5: &str) -> TrackKey { TrackKey { - server_id: String::new(), + server_id: "server-a".to_string(), track_id: track_id.to_string(), md5_16kb: md5.to_string(), } @@ -386,7 +505,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, "server-a", "missing", "deadbeef").unwrap(); assert!(payload.is_none()); } @@ -395,7 +514,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, "server-a", "abc", "deadbeef") .unwrap() .expect("payload exists"); assert_eq!(payload.bins, bins); @@ -411,8 +530,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, "server-a", "abc", "aaaa").unwrap().unwrap(); + let p2 = get_waveform_payload(&cache, "server-a", "abc", "bbbb").unwrap().unwrap(); assert_ne!(p1.bins, p2.bins); } @@ -424,7 +543,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, "server-a", "abc") .unwrap() .expect("bare-id lookup must hit the stream-prefixed row"); assert_eq!(payload.bin_count, 4); @@ -433,28 +552,7 @@ 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()); - } - - #[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()); + assert!(get_waveform_payload_for_track(&cache, "server-a", "phantom").unwrap().is_none()); } // ── get_loudness_payload_for_track ──────────────────────────────────────── @@ -465,7 +563,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, "server-a", "abc", Some(-10.0)) .unwrap() .expect("loudness row exists"); assert_eq!(payload.target_lufs, -10.0); @@ -480,7 +578,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, "server-a", "abc", None) .unwrap() .unwrap(); assert_eq!(payload.target_lufs, -16.0); @@ -491,11 +589,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, "server-a", "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, "server-a", "abc", Some(-100.0)) .unwrap() .unwrap(); assert_eq!(too_low.target_lufs, -30.0); @@ -504,7 +602,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, "server-a", "phantom", None) .unwrap() .is_none()); } diff --git a/src-tauri/crates/psysonic-analysis/src/lib.rs b/src-tauri/crates/psysonic-analysis/src/lib.rs index 3fe7773f..2205461b 100644 --- a/src-tauri/crates/psysonic-analysis/src/lib.rs +++ b/src-tauri/crates/psysonic-analysis/src/lib.rs @@ -6,6 +6,7 @@ //! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop pub mod analysis_cache; +pub mod analysis_perf; pub mod analysis_runtime; pub mod commands; pub mod track_analysis_plan; diff --git a/src-tauri/crates/psysonic-analysis/src/track_enrichment.rs b/src-tauri/crates/psysonic-analysis/src/track_enrichment.rs index ab0d887e..54557ba3 100644 --- a/src-tauri/crates/psysonic-analysis/src/track_enrichment.rs +++ b/src-tauri/crates/psysonic-analysis/src/track_enrichment.rs @@ -5,7 +5,7 @@ use psysonic_core::track_enrichment::{ TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort, TrackEnrichmentPlan, TrackEnrichmentRealFact, }; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; use crate::analysis_cache::{ analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb, @@ -20,7 +20,7 @@ pub struct EnrichmentUpdatedPayload { pub server_id: String, } -fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) { +fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) { let _ = app.emit( "analysis:enrichment-updated", EnrichmentUpdatedPayload { @@ -30,8 +30,8 @@ fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) { ); } -pub fn run_track_enrichment_if_needed( - app: &AppHandle, +pub fn run_track_enrichment_if_needed( + app: &AppHandle, server_id: &str, track_id: &str, bytes: &[u8], diff --git a/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs b/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs index 63dab796..c8b896a5 100644 --- a/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs +++ b/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs @@ -9,8 +9,11 @@ use std::sync::Arc; use tauri::{AppHandle, Manager}; +use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority; + use crate::engine::{analysis_track_id_is_current_playback, AudioEngine}; use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str}; +use url::Url; use crate::state::ChainedInfo; use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES}; @@ -38,41 +41,82 @@ pub(crate) fn resolve_analysis_server_id( explicit: Option<&str>, engine: Option<&AudioEngine>, ) -> String { + if let Some(engine) = engine { + if let Some(url) = engine + .current_playback_url + .lock() + .ok() + .and_then(|g| (*g).clone()) + { + if let Some(derived) = server_id_from_playback_url(&url) { + return derived; + } + } + } explicit .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string) - .unwrap_or_else(|| { - engine - .map(current_playback_server_id_str) - .unwrap_or_default() - }) + .unwrap_or_else(|| engine.map(current_playback_server_id_str).unwrap_or_default()) } -fn resolve_high_priority( +fn server_id_from_playback_url(url_raw: &str) -> Option { + if url_raw.starts_with("psysonic-local://") { + return None; + } + let parsed = Url::parse(url_raw).ok()?; + let host = parsed.host_str()?; + let mut base_path = parsed.path().to_string(); + if let Some(idx) = base_path.find("/rest") { + base_path.truncate(idx); + } + while base_path.ends_with('/') { + base_path.pop(); + } + let mut base = host.to_string(); + if let Some(port) = parsed.port() { + base.push_str(&format!(":{port}")); + } + if !base_path.is_empty() { + base.push_str(&base_path); + } + Some(base) +} + +fn resolve_analysis_priority( app: &AppHandle, engine: Option<&AudioEngine>, + server_id: &str, track_id: &str, - explicit: Option, -) -> bool { - explicit.unwrap_or_else(|| { - psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id) - || engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id)) - }) + explicit: Option, +) -> AnalysisBackfillPriority { + if let Some(priority) = explicit { + return priority; + } + if psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id) + || engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id)) + { + return AnalysisBackfillPriority::High; + } + psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority( + app, + server_id, + track_id, + None, + ) } -/// Resolve `(server_id, high_priority)` when the caller has live engine state. +/// Resolve `(server_id, priority)` when the caller has live engine state. pub(crate) fn prepare_playback_analysis( app: &AppHandle, engine: &AudioEngine, explicit_server_id: Option<&str>, track_id: &str, - high_priority: Option, -) -> (String, bool) { - ( - resolve_analysis_server_id(explicit_server_id, Some(engine)), - resolve_high_priority(app, Some(engine), track_id, high_priority), - ) + priority: Option, +) -> (String, AnalysisBackfillPriority) { + let sid = resolve_analysis_server_id(explicit_server_id, Some(engine)); + let resolved = resolve_analysis_priority(app, Some(engine), &sid, track_id, priority); + (sid, resolved) } pub(crate) fn resolve_server_id_for_app( @@ -83,13 +127,14 @@ pub(crate) fn resolve_server_id_for_app( resolve_analysis_server_id(explicit, engine.as_deref()) } -pub(crate) fn high_priority_for_app( +pub(crate) fn analysis_priority_for_app( app: &AppHandle, + server_id: &str, track_id: &str, - explicit: Option, -) -> bool { + explicit: Option, +) -> AnalysisBackfillPriority { let engine = app.try_state::(); - resolve_high_priority(app, engine.as_deref(), track_id, explicit) + resolve_analysis_priority(app, engine.as_deref(), server_id, track_id, explicit) } /// Gapless boundary: chained track became audible — run unified analysis if needed. @@ -102,12 +147,12 @@ pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedI return; }; let engine = app.state::(); - let (sid, high) = prepare_playback_analysis( + let (sid, priority) = prepare_playback_analysis( app, &engine, info.server_id.as_deref(), &track_id, - Some(true), + Some(AnalysisBackfillPriority::High), ); let bytes = (*info.raw_bytes).clone(); spawn_track_analysis_bytes( @@ -116,7 +161,7 @@ pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedI sid, track_id, bytes, - high, + priority, None, ); } @@ -128,7 +173,7 @@ pub(crate) async fn dispatch_track_analysis_bytes( server_id: &str, track_id: &str, bytes: Vec, - high_priority: bool, + priority: AnalysisBackfillPriority, ) -> Result<(), String> { let track_id = track_id.trim(); if track_id.is_empty() { @@ -146,7 +191,7 @@ pub(crate) async fn dispatch_track_analysis_bytes( return Ok(()); } crate::app_deprintln!( - "[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} high={high_priority}", + "[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} priority={priority:?}", if server_id.is_empty() { "''" } else { server_id }, bytes.len() as f64 / (1024.0 * 1024.0), ); @@ -155,7 +200,7 @@ pub(crate) async fn dispatch_track_analysis_bytes( server_id, track_id, &bytes, - high_priority, + priority, ) .await .map(|_| ()) @@ -168,7 +213,7 @@ pub(crate) fn spawn_track_analysis_bytes( server_id: String, track_id: String, bytes: Vec, - high_priority: bool, + priority: AnalysisBackfillPriority, generation_guard: Option<(u64, Arc)>, ) { if track_id.trim().is_empty() || bytes.is_empty() { @@ -186,7 +231,7 @@ pub(crate) fn spawn_track_analysis_bytes( &server_id, &track_id, bytes, - high_priority, + priority, ) .await { @@ -203,7 +248,7 @@ pub(crate) fn spawn_track_analysis_file( server_id: String, track_id: String, file_path: PathBuf, - high_priority: bool, + priority: AnalysisBackfillPriority, generation_guard: Option<(u64, Arc)>, ) { if track_id.trim().is_empty() { @@ -230,7 +275,7 @@ pub(crate) fn spawn_track_analysis_file( &server_id, &track_id, bytes, - high_priority, + priority, ) .await { diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index 7e7ae178..f07eee43 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -548,12 +548,12 @@ pub async fn audio_chain_preload( let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty()); if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) { - let (sid, high) = crate::analysis_dispatch::prepare_playback_analysis( + let (sid, priority) = crate::analysis_dispatch::prepare_playback_analysis( &app, &state, analysis_server_id, &track_id, - Some(false), + Some(psysonic_analysis::analysis_runtime::AnalysisBackfillPriority::Middle), ); let bytes = (*raw_bytes).clone(); crate::analysis_dispatch::spawn_track_analysis_bytes( @@ -562,7 +562,7 @@ pub async fn audio_chain_preload( sid, track_id, bytes, - high, + priority, None, ); } diff --git a/src-tauri/crates/psysonic-audio/src/preload_commands.rs b/src-tauri/crates/psysonic-audio/src/preload_commands.rs index 92a49d9d..be2069a9 100644 --- a/src-tauri/crates/psysonic-audio/src/preload_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/preload_commands.rs @@ -10,6 +10,8 @@ use std::time::Duration; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; +use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority; + use super::analysis_dispatch::{ dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file, TrackAnalysisOrigin, @@ -36,13 +38,12 @@ async fn seed_preload_analysis_bytes( let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else { return; }; - let (sid, high) = prepare_playback_analysis( + let (sid, priority) = prepare_playback_analysis( app, state, server_id, &track_id, - // Next-track prefetch — never steal CPU from the audible track. - Some(false), + Some(AnalysisBackfillPriority::Middle), ); if let Err(e) = dispatch_track_analysis_bytes( app, @@ -50,7 +51,7 @@ async fn seed_preload_analysis_bytes( &sid, &track_id, data.to_vec(), - high, + priority, ) .await { @@ -69,12 +70,12 @@ fn seed_preload_analysis_file( let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else { return; }; - let (sid, high) = prepare_playback_analysis( + let (sid, priority) = prepare_playback_analysis( app, state, server_id, &track_id, - Some(false), + Some(AnalysisBackfillPriority::Middle), ); crate::app_deprintln!( "[stream] audio_preload: local file analysis track_id={} path={}", @@ -87,7 +88,7 @@ fn seed_preload_analysis_file( sid, track_id, file_path, - high, + priority, None, ); } 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 e73b8423..14e71673 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/ranged_http.rs @@ -27,7 +27,7 @@ use super::{ TRACK_STREAM_PROMOTE_MAX_BYTES, }; use crate::analysis_dispatch::{ - dispatch_track_analysis_bytes, high_priority_for_app, resolve_server_id_for_app, + dispatch_track_analysis_bytes, analysis_priority_for_app, resolve_server_id_for_app, spawn_track_analysis_file, TrackAnalysisOrigin, }; use crate::helpers::{install_stream_completed_spill, write_stream_spill_file}; @@ -647,14 +647,14 @@ pub(crate) async fn ranged_download_task( } if let Some(track_id) = cache_track_id { let sid = resolve_server_id_for_app(&app, server_id.as_deref()); - let high = high_priority_for_app(&app, &track_id, None); + let priority = analysis_priority_for_app(&app, &sid, &track_id, None); if let Err(e) = dispatch_track_analysis_bytes( &app, TrackAnalysisOrigin::StreamDownloadComplete, &sid, &track_id, data.clone(), - high, + priority, ) .await { @@ -691,14 +691,14 @@ pub(crate) async fn ranged_download_task( } install_stream_completed_spill(&spill_cache_slot, url, path.clone()); let sid = resolve_server_id_for_app(&app, server_id.as_deref()); - let high = high_priority_for_app(&app, &track_id, None); + let priority = analysis_priority_for_app(&app, &sid, &track_id, None); spawn_track_analysis_file( app.clone(), TrackAnalysisOrigin::StreamSpillFile, sid, track_id, path, - high, + priority, Some((gen, gen_arc.clone())), ); } 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 2b919132..2b91bb29 100644 --- a/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs +++ b/src-tauri/crates/psysonic-audio/src/stream/track_stream.rs @@ -171,14 +171,14 @@ pub(crate) async fn track_download_task( &app, server_id.as_deref(), ); - let high = crate::analysis_dispatch::high_priority_for_app(&app, &track_id, None); + let priority = crate::analysis_dispatch::analysis_priority_for_app(&app, &sid, &track_id, None); if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes( &app, crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete, &sid, &track_id, capture.clone(), - high, + priority, ) .await { diff --git a/src-tauri/crates/psysonic-core/src/ports.rs b/src-tauri/crates/psysonic-core/src/ports.rs index a49ad09c..26a40224 100644 --- a/src-tauri/crates/psysonic-core/src/ports.rs +++ b/src-tauri/crates/psysonic-core/src/ports.rs @@ -109,3 +109,28 @@ impl AnalysisReadinessQuery { (self.query)(server_id, track_id, md5_16kb) } } + +type NeedsWorkFn = Arc Result + Send + Sync + 'static>; + +/// Library→analysis plan probe: does `(server_id, track_id)` still need waveform, +/// loudness, or enrichment work? Wired in the shell crate so `psysonic-library` +/// can batch-scan without depending on `psysonic-analysis`. +#[derive(Clone)] +pub struct TrackAnalysisNeedsWorkQuery { + query: NeedsWorkFn, +} + +impl TrackAnalysisNeedsWorkQuery { + pub fn new(query: F) -> Self + where + F: Fn(&str, &str) -> Result + Send + Sync + 'static, + { + Self { + query: Arc::new(query), + } + } + + pub fn needs_work(&self, server_id: &str, track_id: &str) -> Result { + (self.query)(server_id, track_id) + } +} diff --git a/src-tauri/crates/psysonic-library/src/analysis_backfill.rs b/src-tauri/crates/psysonic-library/src/analysis_backfill.rs new file mode 100644 index 00000000..bf969244 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/analysis_backfill.rs @@ -0,0 +1,165 @@ +//! Advanced analytics strategy — batch-select library tracks that still need +//! waveform / loudness / enrichment work (spec: Settings → Library). + +use psysonic_core::ports::TrackAnalysisNeedsWorkQuery; +use tauri::{AppHandle, Manager}; + +use crate::repos::TrackRepository; +use crate::runtime::LibraryRuntime; + +const SCAN_CHUNK: usize = 500; +const MAX_SCAN_IDS_PER_CALL: usize = 10_000; +const DEFAULT_BATCH: u32 = 20; +const MAX_BATCH: u32 = 50; +const PROGRESS_SCAN_CHUNK: usize = 1000; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAnalysisBackfillBatchDto { + pub track_ids: Vec, + pub next_cursor: Option, + pub exhausted: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryAnalysisProgressDto { + pub total_tracks: i64, + pub pending_tracks: i64, + pub done_tracks: i64, +} + +enum ScanMode { + Candidates, + Full, +} + +pub fn collect_analysis_backfill_batch( + app: &AppHandle, + runtime: &LibraryRuntime, + server_id: &str, + cursor: Option<&str>, + limit: Option, +) -> Result { + let want = limit.unwrap_or(DEFAULT_BATCH).min(MAX_BATCH) as usize; + let needs_work = app + .try_state::() + .ok_or_else(|| "TrackAnalysisNeedsWorkQuery not registered".to_string())?; + + let repo = TrackRepository::new(&runtime.store); + let mut found = Vec::with_capacity(want); + let mut after = cursor.map(str::to_string); + let mut mode = ScanMode::Candidates; + let mut scanned = 0usize; + + while found.len() < want && scanned < MAX_SCAN_IDS_PER_CALL { + let page = match mode { + ScanMode::Candidates => { + repo.list_analysis_candidate_ids_after(server_id, after.as_deref(), SCAN_CHUNK)? + } + ScanMode::Full => repo.list_track_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?, + }; + + if page.is_empty() { + match mode { + ScanMode::Candidates => { + mode = ScanMode::Full; + continue; + } + ScanMode::Full => { + return Ok(LibraryAnalysisBackfillBatchDto { + track_ids: found, + next_cursor: after, + exhausted: true, + }); + } + } + } + + let page_len = page.len(); + for id in page { + scanned += 1; + after = Some(id.clone()); + if needs_work.needs_work(server_id, &id)? { + found.push(id); + if found.len() >= want { + break; + } + } + if scanned >= MAX_SCAN_IDS_PER_CALL { + break; + } + } + + if found.len() >= want || scanned >= MAX_SCAN_IDS_PER_CALL { + break; + } + + if page_len < SCAN_CHUNK { + match mode { + ScanMode::Candidates => { + mode = ScanMode::Full; + } + ScanMode::Full => { + return Ok(LibraryAnalysisBackfillBatchDto { + track_ids: found, + next_cursor: after, + exhausted: true, + }); + } + } + } + } + + Ok(LibraryAnalysisBackfillBatchDto { + track_ids: found, + next_cursor: after, + exhausted: false, + }) +} + +pub fn collect_analysis_progress( + app: &AppHandle, + runtime: &LibraryRuntime, + server_id: &str, +) -> Result { + let needs_work = app + .try_state::() + .ok_or_else(|| "TrackAnalysisNeedsWorkQuery not registered".to_string())?; + + let repo = TrackRepository::new(&runtime.store); + let total = repo.count_live_tracks(server_id)?; + if total <= 0 { + return Ok(LibraryAnalysisProgressDto { + total_tracks: 0, + pending_tracks: 0, + done_tracks: 0, + }); + } + + let mut pending: i64 = 0; + let mut after: Option = None; + loop { + let page = repo.list_track_ids_after( + server_id, + after.as_deref(), + PROGRESS_SCAN_CHUNK, + )?; + if page.is_empty() { + break; + } + for id in page { + after = Some(id.clone()); + if needs_work.needs_work(server_id, &id)? { + pending += 1; + } + } + } + + let done = total.saturating_sub(pending); + Ok(LibraryAnalysisProgressDto { + total_tracks: total, + pending_tracks: pending, + done_tracks: done, + }) +} diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 7766af63..32bbf2b1 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -15,6 +15,7 @@ use psysonic_integration::navidrome::navidrome_token; use psysonic_integration::subsonic::SubsonicClient; use crate::advanced_search; +use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto}; use crate::cross_server; use crate::dto::{ count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, @@ -41,6 +42,81 @@ 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; +const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryServerKeyMigrationDto { + pub legacy_id: String, + pub index_key: String, +} + +#[tauri::command] +pub fn library_analysis_backfill_batch( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, + cursor: Option, + limit: Option, +) -> Result { + analysis_backfill::collect_analysis_backfill_batch( + &app, + &runtime, + server_id.trim(), + cursor.as_deref().filter(|s| !s.is_empty()), + limit, + ) +} + +#[tauri::command] +pub fn library_analysis_progress( + app: AppHandle, + runtime: State<'_, LibraryRuntime>, + server_id: String, +) -> Result { + let server_id = server_id.trim().to_string(); + if server_id.is_empty() { + return Ok(LibraryAnalysisProgressDto { + total_tracks: 0, + pending_tracks: 0, + done_tracks: 0, + }); + } + + let cached = runtime.analysis_progress_snapshot(&server_id); + if let Some(entry) = cached.as_ref() { + if entry.updated_at.elapsed() <= ANALYSIS_PROGRESS_CACHE_TTL { + return Ok(entry.value.clone()); + } + } + + if runtime.mark_analysis_progress_in_flight(&server_id) { + let app_handle = app.clone(); + let server_id_clone = server_id.clone(); + tauri::async_runtime::spawn_blocking(move || { + let Some(runtime) = app_handle.try_state::() else { + return; + }; + let progress = analysis_backfill::collect_analysis_progress( + &app_handle, + &runtime, + server_id_clone.trim(), + ); + match progress { + Ok(value) => runtime.set_analysis_progress(&server_id_clone, value), + Err(_) => runtime.clear_analysis_progress_in_flight(&server_id_clone), + } + }); + } + + Ok(cached + .map(|entry| entry.value) + .unwrap_or(LibraryAnalysisProgressDto { + total_tracks: 0, + pending_tracks: 0, + done_tracks: 0, + })) +} #[tauri::command] pub async fn library_get_status( @@ -228,8 +304,8 @@ pub async fn library_get_track( .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. + // populated by E2) and probed via the analysis-readiness port. 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 @@ -758,6 +834,9 @@ async fn library_sync_start_inner( &format!("sync task panicked: {join_err}"), ), }; + if let Some(runtime) = app_for_emit.try_state::() { + let _ = runtime.store.checkpoint_wal("sync.checkpoint"); + } let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome); // Clear the slot only if it still names us — sync_start may @@ -1117,6 +1196,17 @@ pub fn library_purge_server( Ok(report) } +#[tauri::command] +pub fn library_migrate_server_index_keys( + _runtime: State<'_, LibraryRuntime>, + mappings: Vec, +) -> Result<(), String> { + for mapping in mappings { + let _ = (mapping.legacy_id, mapping.index_key); + } + Ok(()) +} + #[tauri::command] pub fn library_delete_server_data( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 439165b4..e0f4fdd4 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -10,6 +10,7 @@ pub(crate) mod bulk_ingest; pub mod advanced_search; mod advanced_search_mood; +pub mod analysis_backfill; pub mod canonical; pub mod commands; pub mod cross_server; diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs index 3d45ea66..17908476 100644 --- a/src-tauri/crates/psysonic-library/src/live_search.rs +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -616,7 +616,10 @@ mod tests { use std::time::Instant; let path: PathBuf = std::env::var("HOME") - .map(|h| PathBuf::from(h).join(".local/share/dev.psysonic.player/library.sqlite")) + .map(|h| { + PathBuf::from(h) + .join(".local/share/dev.psysonic.player/databases/library/library.sqlite") + }) .expect("HOME"); if !path.exists() { eprintln!("skip: no db at {}", path.display()); diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index 8df84807..30024972 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -270,6 +270,74 @@ impl<'a> TrackRepository<'a> { }) } + /// Keyset page of track ids for cursor-based library scans (`id ASC`). + pub fn list_track_ids_after( + &self, + server_id: &str, + after_id: Option<&str>, + limit: usize, + ) -> Result, String> { + if limit == 0 { + return Ok(vec![]); + } + let limit = i64::try_from(limit).map_err(|e| e.to_string())?; + self.store.with_read_conn(|conn| { + let sql = "SELECT id FROM track \ + WHERE server_id = ?1 AND deleted = 0 \ + AND (?2 IS NULL OR id > ?2) \ + ORDER BY id ASC LIMIT ?3"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![server_id, after_id, limit], |row| row.get(0))?; + rows.collect::>>() + }) + } + + /// Cheap SQL prefilter: tracks that never received a playback hash and/or + /// lack an oximedia BPM fact. Full analysis gaps are confirmed per id via + /// [`TrackAnalysisNeedsWorkQuery`] in the shell crate. + pub fn list_analysis_candidate_ids_after( + &self, + server_id: &str, + after_id: Option<&str>, + limit: usize, + ) -> Result, String> { + if limit == 0 { + return Ok(vec![]); + } + let limit = i64::try_from(limit).map_err(|e| e.to_string())?; + self.store.with_read_conn(|conn| { + let sql = "SELECT t.id FROM track t \ + WHERE t.server_id = ?1 AND t.deleted = 0 \ + AND (?2 IS NULL OR t.id > ?2) \ + AND ( \ + t.content_hash IS NULL \ + OR NOT EXISTS ( \ + SELECT 1 FROM track_fact f \ + WHERE f.server_id = t.server_id \ + AND f.track_id = t.id \ + AND f.fact_kind = 'bpm' \ + AND f.source_kind = 'analysis' \ + ) \ + ) \ + ORDER BY t.id ASC LIMIT ?3"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![server_id, after_id, limit], |row| row.get(0))?; + rows.collect::>>() + }) + } + + /// Count non-deleted tracks for a server (analysis progress baseline). + pub fn count_live_tracks(&self, server_id: &str) -> Result { + self.store.with_read_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0", + params![server_id], + |row| row.get(0), + ) + }) + .map_err(|e| e.to_string()) + } + /// 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 @@ -1151,4 +1219,41 @@ mod tests { .unwrap(); assert_eq!(count, 0); } + + #[test] + fn list_track_ids_after_pages_in_id_order() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + for id in ["a1", "b2", "c3"] { + let mut r = row("s1", id, id); + r.content_hash = None; + repo.upsert_batch(&[r]).unwrap(); + } + let first = repo.list_track_ids_after("s1", None, 2).unwrap(); + assert_eq!(first, vec!["a1", "b2"]); + let second = repo.list_track_ids_after("s1", Some("b2"), 2).unwrap(); + assert_eq!(second, vec!["c3"]); + } + + #[test] + fn list_analysis_candidate_ids_skips_tracks_with_bpm_fact() { + let store = LibraryStore::open_in_memory(); + let repo = TrackRepository::new(&store); + let mut needs = row("s1", "needs", "Needs"); + needs.content_hash = None; + repo.upsert_batch(&[needs, row("s1", "done", "Done")]).unwrap(); + store + .with_conn_mut("misc", |c| { + c.execute( + "INSERT INTO track_fact (server_id, track_id, fact_kind, source_kind, source_id, confidence, fetched_at) \ + VALUES ('s1', 'done', 'bpm', 'analysis', 'oximedia-60s-center', 1.0, 1)", + [], + ) + }) + .unwrap(); + let ids = repo + .list_analysis_candidate_ids_after("s1", None, 10) + .unwrap(); + assert_eq!(ids, vec!["needs"]); + } } diff --git a/src-tauri/crates/psysonic-library/src/runtime.rs b/src-tauri/crates/psysonic-library/src/runtime.rs index 97b9fc65..296ef521 100644 --- a/src-tauri/crates/psysonic-library/src/runtime.rs +++ b/src-tauri/crates/psysonic-library/src/runtime.rs @@ -10,12 +10,21 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use tokio::sync::Notify; +use crate::analysis_backfill::LibraryAnalysisProgressDto; use crate::store::LibraryStore; use crate::sync::bandwidth::PlaybackHint; +#[derive(Debug, Clone)] +pub struct AnalysisProgressCacheEntry { + pub value: LibraryAnalysisProgressDto, + pub updated_at: Instant, + pub in_flight: bool, +} + /// Per-server credentials cache for the sync runner. Lives only in /// `LibraryRuntime` process memory; `library_sync_clear_session` /// removes it on logout / index disable / purge. @@ -67,6 +76,8 @@ pub struct LibraryRuntime { /// Latest `library_live_search` epoch from the UI — stale commands /// skip FTS when a newer keystroke generation was registered. live_search_epoch: AtomicU64, + /// Cached analysis progress snapshots keyed by server id. + analysis_progress_cache: Mutex>, } impl LibraryRuntime { @@ -78,6 +89,7 @@ impl LibraryRuntime { current_job: Mutex::new(None), scheduler_cancel: Arc::new(AtomicBool::new(false)), live_search_epoch: AtomicU64::new(0), + analysis_progress_cache: Mutex::new(HashMap::new()), } } @@ -162,6 +174,71 @@ impl LibraryRuntime { *h = hint; } } + + pub fn analysis_progress_snapshot( + &self, + server_id: &str, + ) -> Option { + self.analysis_progress_cache + .lock() + .ok() + .and_then(|cache| cache.get(server_id).cloned()) + } + + pub fn mark_analysis_progress_in_flight(&self, server_id: &str) -> bool { + if let Ok(mut cache) = self.analysis_progress_cache.lock() { + match cache.get_mut(server_id) { + Some(entry) => { + if entry.in_flight { + return false; + } + entry.in_flight = true; + return true; + } + None => { + cache.insert( + server_id.to_string(), + AnalysisProgressCacheEntry { + value: LibraryAnalysisProgressDto { + total_tracks: 0, + pending_tracks: 0, + done_tracks: 0, + }, + updated_at: Instant::now() - Duration::from_secs(60), + in_flight: true, + }, + ); + return true; + } + } + } + false + } + + pub fn set_analysis_progress( + &self, + server_id: &str, + value: LibraryAnalysisProgressDto, + ) { + if let Ok(mut cache) = self.analysis_progress_cache.lock() { + cache.insert( + server_id.to_string(), + AnalysisProgressCacheEntry { + value, + updated_at: Instant::now(), + in_flight: false, + }, + ); + } + } + + pub fn clear_analysis_progress_in_flight(&self, server_id: &str) { + if let Ok(mut cache) = self.analysis_progress_cache.lock() { + if let Some(entry) = cache.get_mut(server_id) { + entry.in_flight = false; + } + } + } } #[cfg(test)] diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index cb55053c..7088ab7a 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::{fs, io}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -89,6 +90,7 @@ impl LibraryStore { 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())?; + checkpoint_wal_conn(&write_conn, "open").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())?; @@ -179,6 +181,108 @@ impl LibraryStore { log_write_op(op, lock_wait_ms as u128, exec_ms as u128); Ok((out, WriteOpTiming { lock_wait_ms, exec_ms })) } + + pub(crate) fn checkpoint_wal(&self, op: &'static str) -> Result<(), String> { + self.with_conn_mut(op, |conn| { + checkpoint_wal_conn(conn, op)?; + Ok(()) + }) + } + + /// Atomically switch the active sqlite file while replacing long-lived + /// write/read connections under the same locks so no command can keep + /// writing to the old inode after the swap. + pub fn swap_database_file( + &self, + active_path: &Path, + destination_path: &Path, + ) -> Result, String> { + if !destination_path.exists() { + return Ok(None); + } + let mut write_conn = self + .write_conn + .lock() + .map_err(|_| "library store write lock poisoned".to_string())?; + let mut read_conn = self + .read_conn + .lock() + .map_err(|_| "library store read lock poisoned".to_string())?; + + let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let old_write = std::mem::replace(&mut *write_conn, write_tmp); + let old_read = std::mem::replace(&mut *read_conn, read_tmp); + drop(old_write); + drop(old_read); + + let backup = active_path.with_file_name(format!( + "{}.backup-pre-indexkey", + active_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("library.sqlite") + )); + remove_db_with_sidecars(&backup).ok(); + if active_path.exists() { + fs::rename(active_path, &backup).map_err(|e| e.to_string())?; + move_sidecar(active_path, &backup, "-wal")?; + move_sidecar(active_path, &backup, "-shm")?; + } + if let Err(err) = fs::rename(destination_path, active_path) { + if backup.exists() { + let _ = fs::rename(&backup, active_path); + let _ = move_sidecar(&backup, active_path, "-wal"); + let _ = move_sidecar(&backup, active_path, "-shm"); + } + return Err(err.to_string()); + } + + let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?; + configure_write_connection(&reopened_write).map_err(|e| e.to_string())?; + let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + configure_read_connection(&reopened_read).map_err(|e| e.to_string())?; + *write_conn = reopened_write; + *read_conn = reopened_read; + Ok(Some(backup)) + } + + pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> { + let mut write_conn = self + .write_conn + .lock() + .map_err(|_| "library store write lock poisoned".to_string())?; + let mut read_conn = self + .read_conn + .lock() + .map_err(|_| "library store read lock poisoned".to_string())?; + + let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let old_write = std::mem::replace(&mut *write_conn, write_tmp); + let old_read = std::mem::replace(&mut *read_conn, read_tmp); + drop(old_write); + drop(old_read); + + if active_path.exists() { + remove_db_with_sidecars(active_path)?; + } + if backup_path.exists() { + fs::rename(backup_path, active_path).map_err(|e| e.to_string())?; + move_sidecar(backup_path, active_path, "-wal")?; + move_sidecar(backup_path, active_path, "-shm")?; + } + + let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?; + configure_write_connection(&reopened_write).map_err(|e| e.to_string())?; + let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + configure_read_connection(&reopened_read).map_err(|e| e.to_string())?; + *write_conn = reopened_write; + *read_conn = reopened_read; + Ok(()) + } } /// Timing split returned to ingest progress (DevTools / terminal). @@ -206,7 +310,91 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) { 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")) + let db_dir = base.join("databases").join("library"); + let db_path = db_dir.join("library.sqlite"); + let legacy = base.join("library.sqlite"); + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + if db_path.exists() { + cleanup_legacy_db_if_present(&legacy, &db_path)?; + return Ok(db_path); + } + + if legacy.exists() { + migrate_db_file(&legacy, &db_path).map_err(|e| e.to_string())?; + migrate_db_sidecar(&legacy, &db_path, "-wal").map_err(|e| e.to_string())?; + migrate_db_sidecar(&legacy, &db_path, "-shm").map_err(|e| e.to_string())?; + } + cleanup_legacy_db_if_present(&legacy, &db_path)?; + + Ok(db_path) +} + +fn cleanup_legacy_db_if_present(legacy_path: &Path, active_path: &Path) -> Result<(), String> { + if legacy_path == active_path { + return Ok(()); + } + remove_db_with_sidecars(legacy_path) +} + +fn migrate_db_file(from: &Path, to: &Path) -> io::Result<()> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + match fs::rename(from, to) { + Ok(()) => Ok(()), + Err(_) => { + fs::copy(from, to)?; + fs::remove_file(from)?; + Ok(()) + } + } +} + +fn migrate_db_sidecar(from: &Path, to: &Path, suffix: &str) -> io::Result<()> { + let from_path = PathBuf::from(format!("{}{}", from.display(), suffix)); + if !from_path.exists() { + return Ok(()); + } + let to_path = PathBuf::from(format!("{}{}", to.display(), suffix)); + if let Some(parent) = to_path.parent() { + fs::create_dir_all(parent)?; + } + match fs::rename(&from_path, &to_path) { + Ok(()) => Ok(()), + Err(_) => { + fs::copy(&from_path, &to_path)?; + fs::remove_file(&from_path)?; + Ok(()) + } + } +} + +fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> { + let from = PathBuf::from(format!("{}{}", from_base.display(), suffix)); + if !from.exists() { + return Ok(()); + } + let to = PathBuf::from(format!("{}{}", to_base.display(), suffix)); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::rename(from, to).map_err(|e| e.to_string()) +} + +fn remove_db_with_sidecars(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix)); + if sidecar.exists() { + fs::remove_file(sidecar).map_err(|e| e.to_string())?; + } + } + Ok(()) } fn configure_write_connection(conn: &Connection) -> rusqlite::Result<()> { @@ -226,6 +414,19 @@ fn configure_read_connection(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } +fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> { + let (busy, log, checkpointed): (i32, i32, i32) = + conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + if busy != 0 { + crate::app_eprintln!( + "[library-db] wal checkpoint busy op={op} busy={busy} log={log} checkpointed={checkpointed}" + ); + } + Ok(()) +} + fn run_migrations(conn: &Connection) -> rusqlite::Result { run_migrations_with( conn, diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index 8c01ff70..0ab5706c 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -1,4 +1,4 @@ -use psysonic_analysis::analysis_runtime::enqueue_track_analysis; +use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority}; use psysonic_audio as audio; use psysonic_core::user_agent::subsonic_wire_user_agent; @@ -43,7 +43,14 @@ pub async fn download_track_hot_cache( let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; + enqueue_analysis_seed_from_file( + &app_seed, + &sid, + &tid, + &fp, + Some(AnalysisBackfillPriority::Middle), + ) + .await; }); return Ok(HotCacheDownloadResult { path: path_str, @@ -83,7 +90,14 @@ pub async fn download_track_hot_cache( let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; + enqueue_analysis_seed_from_file( + &app_seed, + &sid, + &tid, + &fp, + Some(AnalysisBackfillPriority::Middle), + ) + .await; }); let size = tokio::fs::metadata(&file_path) @@ -139,7 +153,7 @@ pub async fn promote_stream_cache_to_hot_cache( let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp, None).await; }); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } @@ -154,9 +168,13 @@ pub async fn promote_stream_cache_to_hot_cache( .await .map_err(|e| e.to_string())?; - let high = - psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(&app, &track_id); - let _ = enqueue_track_analysis(&app, &server_id, &track_id, &bytes, high).await; + let priority = psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority( + &app, + &server_id, + &track_id, + None, + ); + let _ = enqueue_track_analysis(&app, &server_id, &track_id, &bytes, priority).await; let size = tokio::fs::metadata(&file_path) .await @@ -184,7 +202,7 @@ pub async fn promote_stream_cache_to_hot_cache( let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { - enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await; + enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp, None).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 1f505d44..8e30a62b 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use tauri::Manager; use psysonic_analysis::analysis_runtime::{ - analysis_backfill_is_current_track, enqueue_track_analysis_from_file, + analysis_backfill_resolve_priority, enqueue_track_analysis_from_file, + AnalysisBackfillPriority, }; use crate::{offline_cancel_flags, DownloadSemaphore}; @@ -17,9 +18,10 @@ pub async fn enqueue_analysis_seed_from_file( server_id: &str, track_id: &str, file_path: &std::path::Path, + explicit_priority: Option, ) { - let high = analysis_backfill_is_current_track(app, track_id); - let _ = enqueue_track_analysis_from_file(app, server_id, track_id, file_path, high).await; + let priority = analysis_backfill_resolve_priority(app, server_id, track_id, explicit_priority); + let _ = enqueue_track_analysis_from_file(app, server_id, track_id, file_path, priority).await; } /// AppHandle-free download primitive: ensures `cache_dir` exists, returns @@ -137,7 +139,7 @@ pub async fn download_track_offline( ) .await?; - enqueue_analysis_seed_from_file(&app, &server_id, &track_id, &final_path).await; + enqueue_analysis_seed_from_file(&app, &server_id, &track_id, &final_path, None).await; Ok(path_str) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef375e80..399b0e2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,8 @@ pub fn run() { app.manage(handle); } + app.manage(psysonic_analysis::analysis_runtime::PlaybackPriorityHints::default()); + // ── 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 @@ -274,6 +276,22 @@ pub fn run() { app.manage(query); } + // ── Analysis needs-work probe (library → analysis batch scan) ── + { + use psysonic_core::ports::TrackAnalysisNeedsWorkQuery; + let app_for_needs_work = app.handle().clone(); + let needs_work = TrackAnalysisNeedsWorkQuery::new( + move |server_id: &str, track_id: &str| { + psysonic_analysis::analysis_runtime::track_analysis_needs_work( + &app_for_needs_work, + server_id, + track_id, + ) + }, + ); + app.manage(needs_work); + } + // ── Track enrichment port (analysis → library facts) ─────────── { use psysonic_core::track_enrichment::{TrackEnrichmentPlan, TrackEnrichmentPort}; @@ -545,6 +563,12 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ greet, + backup_export_library_db, + backup_import_library_db, + backup_export_full, + backup_import_full, + migration_inspect, + migration_run, psysonic_syncfs::sync::batch::calculate_sync_payload, exit_app, cli_publish_player_snapshot, @@ -636,7 +660,13 @@ pub fn run() { psysonic_analysis::commands::analysis_delete_loudness_for_track, psysonic_analysis::commands::analysis_delete_waveform_for_track, psysonic_analysis::commands::analysis_delete_all_waveforms, + psysonic_analysis::commands::analysis_delete_all_for_server, + psysonic_analysis::commands::analysis_migrate_server_index_keys, psysonic_analysis::commands::analysis_enqueue_seed_from_url, + psysonic_analysis::commands::analysis_set_playback_priority_hints, + psysonic_analysis::commands::analysis_set_pipeline_parallelism, + psysonic_analysis::commands::analysis_get_pipeline_queue_stats, + psysonic_analysis::commands::analysis_get_backfill_queue_stats, psysonic_analysis::commands::analysis_prune_pending_to_track_ids, psysonic_library::commands::library_get_status, psysonic_library::commands::library_search, @@ -649,6 +679,7 @@ pub fn run() { psysonic_library::commands::library_get_artifact, psysonic_library::commands::library_get_facts, psysonic_library::commands::library_get_offline_path, + psysonic_library::commands::library_analysis_progress, psysonic_library::commands::library_sync_bind_session, psysonic_library::commands::library_sync_clear_session, psysonic_library::commands::library_set_playback_hint, @@ -666,7 +697,9 @@ pub fn run() { psysonic_library::commands::library_get_player_stats_year_bounds, psysonic_library::commands::library_get_player_stats_recent_days, psysonic_library::commands::library_purge_server, + psysonic_library::commands::library_migrate_server_index_keys, psysonic_library::commands::library_delete_server_data, + psysonic_library::commands::library_analysis_backfill_batch, 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-tauri/src/lib_commands/app_api/backup.rs b/src-tauri/src/lib_commands/app_api/backup.rs new file mode 100644 index 00000000..cdb20bb6 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/backup.rs @@ -0,0 +1,447 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OpenFlags}; +use serde_json::Value; +use tauri::{AppHandle, Manager}; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +const LIBRARY_ARCHIVE_ENTRY: &str = "library.sqlite"; +const ANALYSIS_ARCHIVE_ENTRY: &str = "audio-analysis.sqlite"; +const FULL_ARCHIVE_SETTINGS_ENTRY: &str = "settings.json"; +const FULL_ARCHIVE_VERSION: u64 = 1; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct FullBackupPayload { + version: u64, + app_version: String, + stores: Value, +} + +#[tauri::command] +pub(crate) async fn backup_export_library_db( + app: AppHandle, + destination_path: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + backup_export_library_db_blocking(&app, destination_path) + }) + .await + .map_err(|e| e.to_string())? +} + +fn backup_export_library_db_blocking(app: &AppHandle, destination_path: String) -> Result<(), String> { + let destination = PathBuf::from(destination_path); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let source_library = library_db_path(app)?; + let source_analysis = analysis_db_path(app)?; + if !source_library.exists() { + return Err("library database does not exist".to_string()); + } + if !source_analysis.exists() { + return Err("analysis database does not exist".to_string()); + } + remove_if_exists(&destination)?; + + let snapshot_library_tmp = source_library.with_file_name("library-export.sqlite"); + let snapshot_analysis_tmp = source_analysis.with_file_name("audio-analysis-export.sqlite"); + remove_db_with_sidecars(&snapshot_library_tmp)?; + remove_db_with_sidecars(&snapshot_analysis_tmp)?; + vacuum_copy(&source_library, &snapshot_library_tmp)?; + vacuum_copy(&source_analysis, &snapshot_analysis_tmp)?; + let result = write_databases_archive( + &snapshot_library_tmp, + &snapshot_analysis_tmp, + &destination, + ); + remove_db_with_sidecars(&snapshot_library_tmp).ok(); + remove_db_with_sidecars(&snapshot_analysis_tmp).ok(); + result +} + +#[tauri::command] +pub(crate) async fn backup_import_library_db(app: AppHandle, source_path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + backup_import_library_db_blocking(&app, source_path) + }) + .await + .map_err(|e| e.to_string())? +} + +fn backup_import_library_db_blocking(app: &AppHandle, source_path: String) -> Result<(), String> { + let source = PathBuf::from(source_path); + if !source.exists() { + return Err("backup file not found".to_string()); + } + + let active_library = library_db_path(app)?; + let active_analysis = analysis_db_path(app)?; + let import_library_tmp = active_library.with_file_name("library-import.sqlite"); + let import_analysis_tmp = active_analysis.with_file_name("audio-analysis-import.sqlite"); + remove_db_with_sidecars(&import_library_tmp)?; + remove_db_with_sidecars(&import_analysis_tmp)?; + extract_databases_archive(&source, &import_library_tmp, &import_analysis_tmp)?; + validate_sqlite_file(&import_library_tmp)?; + validate_sqlite_file(&import_analysis_tmp)?; + import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp) +} + +#[tauri::command] +pub(crate) async fn backup_export_full( + app: AppHandle, + destination_path: String, + stores: Value, + app_version: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + backup_export_full_blocking(&app, destination_path, stores, app_version) + }) + .await + .map_err(|e| e.to_string())? +} + +fn backup_export_full_blocking( + app: &AppHandle, + destination_path: String, + stores: Value, + app_version: String, +) -> Result<(), String> { + if !stores.is_object() { + return Err("stores payload must be an object".to_string()); + } + let destination = PathBuf::from(destination_path); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + remove_if_exists(&destination)?; + + let source_library = library_db_path(app)?; + let source_analysis = analysis_db_path(app)?; + if !source_library.exists() { + return Err("library database does not exist".to_string()); + } + if !source_analysis.exists() { + return Err("analysis database does not exist".to_string()); + } + let snapshot_library_tmp = source_library.with_file_name("library-export.sqlite"); + let snapshot_analysis_tmp = source_analysis.with_file_name("audio-analysis-export.sqlite"); + remove_db_with_sidecars(&snapshot_library_tmp)?; + remove_db_with_sidecars(&snapshot_analysis_tmp)?; + vacuum_copy(&source_library, &snapshot_library_tmp)?; + vacuum_copy(&source_analysis, &snapshot_analysis_tmp)?; + + let payload = FullBackupPayload { + version: FULL_ARCHIVE_VERSION, + app_version, + stores, + }; + let result = write_full_archive( + &snapshot_library_tmp, + &snapshot_analysis_tmp, + &destination, + &payload, + ); + remove_db_with_sidecars(&snapshot_library_tmp).ok(); + remove_db_with_sidecars(&snapshot_analysis_tmp).ok(); + result +} + +#[tauri::command] +pub(crate) async fn backup_import_full(app: AppHandle, source_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || backup_import_full_blocking(&app, source_path)) + .await + .map_err(|e| e.to_string())? +} + +fn backup_import_full_blocking(app: &AppHandle, source_path: String) -> Result { + let source = PathBuf::from(source_path); + if !source.exists() { + return Err("backup file not found".to_string()); + } + + let active_library = library_db_path(app)?; + let active_analysis = analysis_db_path(app)?; + let import_library_tmp = active_library.with_file_name("library-import.sqlite"); + let import_analysis_tmp = active_analysis.with_file_name("audio-analysis-import.sqlite"); + remove_db_with_sidecars(&import_library_tmp)?; + remove_db_with_sidecars(&import_analysis_tmp)?; + let payload = extract_full_archive(&source, &import_library_tmp, &import_analysis_tmp)?; + validate_sqlite_file(&import_library_tmp)?; + validate_sqlite_file(&import_analysis_tmp)?; + let stores = payload.stores; + if !stores.is_object() { + remove_db_with_sidecars(&import_library_tmp).ok(); + remove_db_with_sidecars(&import_analysis_tmp).ok(); + return Err("backup payload stores must be an object".to_string()); + } + + import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp)?; + Ok(stores) +} + +fn library_db_path(app: &AppHandle) -> Result { + let base = app.path().app_data_dir().map_err(|e| e.to_string())?; + let dir = base.join("databases").join("library"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("library.sqlite")) +} + +fn analysis_db_path(app: &AppHandle) -> Result { + let base = app.path().app_data_dir().map_err(|e| e.to_string())?; + let dir = base.join("databases").join("analysis"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("audio-analysis.sqlite")) +} + +fn vacuum_copy(source: &Path, destination: &Path) -> Result<(), String> { + let conn = Connection::open_with_flags(source, OpenFlags::SQLITE_OPEN_READ_WRITE) + .map_err(|e| e.to_string())?; + let escaped = destination.to_string_lossy().replace('\'', "''"); + let sql = format!("VACUUM INTO '{escaped}';"); + conn.execute_batch(&sql).map_err(|e| e.to_string()) +} + +fn write_databases_archive( + library_snapshot: &Path, + analysis_snapshot: &Path, + destination_archive: &Path, +) -> Result<(), String> { + let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?; + let mut zip = ZipWriter::new(file); + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + zip.start_file(LIBRARY_ARCHIVE_ENTRY, options) + .map_err(|e| e.to_string())?; + let mut src = fs::File::open(library_snapshot).map_err(|e| e.to_string())?; + io::copy(&mut src, &mut zip).map_err(|e| e.to_string())?; + zip.start_file(ANALYSIS_ARCHIVE_ENTRY, options) + .map_err(|e| e.to_string())?; + let mut analysis_src = fs::File::open(analysis_snapshot).map_err(|e| e.to_string())?; + io::copy(&mut analysis_src, &mut zip).map_err(|e| e.to_string())?; + zip.finish().map_err(|e| e.to_string())?; + Ok(()) +} + +fn write_full_archive( + library_snapshot: &Path, + analysis_snapshot: &Path, + destination_archive: &Path, + payload: &FullBackupPayload, +) -> Result<(), String> { + let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?; + let mut zip = ZipWriter::new(file); + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + + zip.start_file(FULL_ARCHIVE_SETTINGS_ENTRY, options) + .map_err(|e| e.to_string())?; + let settings = serde_json::to_vec_pretty(payload).map_err(|e| e.to_string())?; + zip.write_all(&settings).map_err(|e| e.to_string())?; + + zip.start_file(LIBRARY_ARCHIVE_ENTRY, options) + .map_err(|e| e.to_string())?; + let mut src = fs::File::open(library_snapshot).map_err(|e| e.to_string())?; + io::copy(&mut src, &mut zip).map_err(|e| e.to_string())?; + + zip.start_file(ANALYSIS_ARCHIVE_ENTRY, options) + .map_err(|e| e.to_string())?; + let mut analysis_src = fs::File::open(analysis_snapshot).map_err(|e| e.to_string())?; + io::copy(&mut analysis_src, &mut zip).map_err(|e| e.to_string())?; + zip.finish().map_err(|e| e.to_string())?; + Ok(()) +} + +fn extract_databases_archive( + source_archive: &Path, + library_destination_sqlite: &Path, + analysis_destination_sqlite: &Path, +) -> Result<(), String> { + let file = fs::File::open(source_archive).map_err(|e| e.to_string())?; + let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?; + let library_target_index = archive + .file_names() + .enumerate() + .find_map(|(i, name)| (name == LIBRARY_ARCHIVE_ENTRY).then_some(i)) + .ok_or_else(|| "archive does not contain library.sqlite".to_string())?; + let analysis_target_index = archive + .file_names() + .enumerate() + .find_map(|(i, name)| (name == ANALYSIS_ARCHIVE_ENTRY).then_some(i)) + .ok_or_else(|| "archive does not contain audio-analysis.sqlite".to_string())?; + + if let Some(parent) = library_destination_sqlite.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + { + let mut library_entry = archive.by_index(library_target_index).map_err(|e| e.to_string())?; + let mut out = fs::File::create(library_destination_sqlite).map_err(|e| e.to_string())?; + io::copy(&mut library_entry, &mut out).map_err(|e| e.to_string())?; + } + + if let Some(parent) = analysis_destination_sqlite.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + { + let mut analysis_entry = archive.by_index(analysis_target_index).map_err(|e| e.to_string())?; + let mut analysis_out = fs::File::create(analysis_destination_sqlite).map_err(|e| e.to_string())?; + io::copy(&mut analysis_entry, &mut analysis_out).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn extract_full_archive( + source_archive: &Path, + library_destination_sqlite: &Path, + analysis_destination_sqlite: &Path, +) -> Result { + let file = fs::File::open(source_archive).map_err(|e| e.to_string())?; + let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?; + + let payload = { + let mut entry = archive + .by_name(FULL_ARCHIVE_SETTINGS_ENTRY) + .map_err(|_| "archive does not contain settings.json".to_string())?; + let mut buf = Vec::new(); + io::copy(&mut entry, &mut buf).map_err(|e| e.to_string())?; + serde_json::from_slice::(&buf).map_err(|e| e.to_string())? + }; + if payload.version != FULL_ARCHIVE_VERSION { + return Err("unsupported full backup version".to_string()); + } + + extract_databases_archive( + source_archive, + library_destination_sqlite, + analysis_destination_sqlite, + )?; + Ok(payload) +} + +fn validate_sqlite_file(path: &Path) -> Result<(), String> { + let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + let integrity: String = conn + .query_row("PRAGMA integrity_check;", [], |row| row.get(0)) + .map_err(|e| e.to_string())?; + if integrity != "ok" { + return Err("backup file integrity check failed".to_string()); + } + Ok(()) +} + +fn library_health_check(active_path: &Path) -> Result<(), String> { + let conn = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + conn.query_row("SELECT COUNT(*) FROM track", [], |_row| Ok(())) + .map_err(|e| e.to_string()) +} + +fn analysis_health_check(active_path: &Path) -> Result<(), String> { + let conn = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(())) + .map_err(|e| e.to_string()) +} + +fn import_databases_from_sqlite( + app: &AppHandle, + import_library_tmp: &Path, + import_analysis_tmp: &Path, +) -> Result<(), String> { + let active_path = library_db_path(app)?; + let analysis_active_path = analysis_db_path(app)?; + let Some(runtime) = app.try_state::() else { + remove_db_with_sidecars(import_library_tmp).ok(); + remove_db_with_sidecars(import_analysis_tmp).ok(); + return Err("library runtime unavailable".to_string()); + }; + let Some(cache) = app.try_state::() else { + remove_db_with_sidecars(import_library_tmp).ok(); + remove_db_with_sidecars(import_analysis_tmp).ok(); + return Err("analysis runtime unavailable".to_string()); + }; + + let library_backup = runtime + .store + .swap_database_file(&active_path, import_library_tmp)? + .ok_or_else(|| "import switch failed".to_string())?; + let analysis_backup = match cache.swap_database_file(&analysis_active_path, import_analysis_tmp) { + Ok(Some(backup)) => backup, + Ok(None) => { + let _ = runtime.store.restore_database_backup(&library_backup, &active_path); + let _ = remove_db_with_sidecars(&library_backup); + let _ = remove_db_with_sidecars(import_library_tmp); + let _ = remove_db_with_sidecars(import_analysis_tmp); + return Err("analysis import switch failed".to_string()); + } + Err(err) => { + let _ = runtime.store.restore_database_backup(&library_backup, &active_path); + let _ = remove_db_with_sidecars(&library_backup); + let _ = remove_db_with_sidecars(import_library_tmp); + let _ = remove_db_with_sidecars(import_analysis_tmp); + return Err(err); + } + }; + + if let Err(err) = library_health_check(&active_path) + .and_then(|_| analysis_health_check(&analysis_active_path)) + { + let _ = runtime.store.restore_database_backup(&library_backup, &active_path); + let _ = cache.restore_database_backup(&analysis_backup, &analysis_active_path); + let _ = remove_db_with_sidecars(&library_backup); + let _ = remove_db_with_sidecars(&analysis_backup); + let _ = remove_db_with_sidecars(import_library_tmp); + let _ = remove_db_with_sidecars(import_analysis_tmp); + return Err(err); + } + + let library_bak_path = active_path.with_file_name("library.sqlite.import.bak"); + remove_db_with_sidecars(&library_bak_path).ok(); + if library_backup.exists() { + fs::rename(&library_backup, &library_bak_path).map_err(|e| e.to_string())?; + move_sidecar(&library_backup, &library_bak_path, "-wal")?; + move_sidecar(&library_backup, &library_bak_path, "-shm")?; + } + + let analysis_bak_path = analysis_active_path.with_file_name("audio-analysis.sqlite.import.bak"); + remove_db_with_sidecars(&analysis_bak_path).ok(); + if analysis_backup.exists() { + fs::rename(&analysis_backup, &analysis_bak_path).map_err(|e| e.to_string())?; + move_sidecar(&analysis_backup, &analysis_bak_path, "-wal")?; + move_sidecar(&analysis_backup, &analysis_bak_path, "-shm")?; + } + + remove_db_with_sidecars(import_library_tmp).ok(); + remove_db_with_sidecars(import_analysis_tmp).ok(); + Ok(()) +} + +fn remove_db_with_sidecars(path: &Path) -> Result<(), String> { + remove_if_exists(path)?; + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{}", path.to_string_lossy(), suffix)); + remove_if_exists(&sidecar)?; + } + Ok(()) +} + +fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> { + let from = PathBuf::from(format!("{}{}", from_base.display(), suffix)); + if !from.exists() { + return Ok(()); + } + let to = PathBuf::from(format!("{}{}", to_base.display(), suffix)); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::rename(from, to).map_err(|e| e.to_string()) +} + +fn remove_if_exists(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/src-tauri/src/lib_commands/app_api/core.rs b/src-tauri/src/lib_commands/app_api/core.rs index 89792878..aae531cd 100644 --- a/src-tauri/src/lib_commands/app_api/core.rs +++ b/src-tauri/src/lib_commands/app_api/core.rs @@ -2,6 +2,7 @@ use tauri::Manager; use crate::lib_commands::sync::stop_audio_engine; use crate::runtime_subsonic_wire_user_agent; +use crate::analysis_cache; #[tauri::command] pub(crate) fn greet(name: &str) -> String { @@ -10,6 +11,9 @@ pub(crate) fn greet(name: &str) -> String { #[tauri::command] pub(crate) fn exit_app(app_handle: tauri::AppHandle) { + if let Some(cache) = app_handle.try_state::() { + let _ = cache.checkpoint_wal("exit"); + } stop_audio_engine(&app_handle); app_handle.exit(0); } diff --git a/src-tauri/src/lib_commands/app_api/migration.rs b/src-tauri/src/lib_commands/app_api/migration.rs new file mode 100644 index 00000000..340b2aae --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/migration.rs @@ -0,0 +1,756 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use rusqlite::{params_from_iter, Connection, OpenFlags}; +use tauri::{AppHandle, Emitter, Manager}; + +const LIBRARY_TABLES: &[&str] = &[ + "track_extension", + "track_fact", + "track_artifact", + "track_canonical_link", + "track_id_history", + "play_session", + "track_offline", + "track", + "album", + "artist", + "sync_state", +]; + +const ANALYSIS_TABLES: &[&str] = &["analysis_track", "waveform_cache", "loudness_cache"]; + +fn migration_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerIndexMapping { + pub legacy_id: String, + pub index_key: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationScopeInspect { + pub total_legacy_rows: u64, + pub skipped_unknown_server_rows: u64, + pub tables: HashMap, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationInspectReport { + pub needs_migration: bool, + pub has_skipped_unknown_server_rows: bool, + pub can_run: bool, + pub warnings: Vec, + pub unmapped_empty_bucket: bool, + pub library: MigrationScopeInspect, + pub analysis: MigrationScopeInspect, + pub mappings: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationProgressEvent { + pub stage: String, + pub table: String, + pub done: u64, + pub total: u64, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationRunScope { + pub imported_rows: u64, + pub source_rows: u64, + pub skipped_unknown_server_rows: u64, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationRunResult { + pub library: MigrationRunScope, + pub analysis: MigrationRunScope, + pub has_skipped_unknown_server_rows: bool, + pub switched: bool, + pub backup_removed: bool, +} + +#[tauri::command] +pub fn migration_inspect( + app: AppHandle, + mappings: Vec, +) -> Result { + inspect_internal(&app, mappings) +} + +#[tauri::command] +pub fn migration_run( + app: AppHandle, + mappings: Vec, +) -> Result { + let _guard = migration_lock() + .lock() + .map_err(|_| "migration lock poisoned".to_string())?; + run_internal(&app, mappings) +} + +fn inspect_internal( + app: &AppHandle, + mappings: Vec, +) -> Result { + let normalized = normalize_mappings(mappings); + let legacy_ids: Vec = normalized.iter().map(|m| m.legacy_id.clone()).collect(); + let index_keys: Vec = normalized.iter().map(|m| m.index_key.clone()).collect(); + let paths = migration_paths(app)?; + + let (library_tables, library_total, library_skipped_unknown_rows) = inspect_tables( + &paths.library_active, + LIBRARY_TABLES, + &legacy_ids, + &index_keys, + )?; + let (analysis_tables, mut analysis_total, analysis_skipped_unknown_rows) = inspect_tables( + &paths.analysis_active, + ANALYSIS_TABLES, + &legacy_ids, + &index_keys, + )?; + let mut analysis_tables = analysis_tables; + let mut warnings = Vec::new(); + let mut unmapped_empty_bucket = false; + let mut has_empty_bucket_rows = false; + if paths.analysis_active.exists() { + let conn = open_readonly(&paths.analysis_active)?; + for table in ANALYSIS_TABLES { + let empty_count = count_rows_eq(&conn, table, "")?; + if empty_count > 0 { + has_empty_bucket_rows = true; + if normalized.len() == 1 { + let entry = analysis_tables.entry((*table).to_string()).or_insert(0); + *entry = entry.saturating_add(empty_count as u64); + analysis_total = analysis_total.saturating_add(empty_count as u64); + } + } + } + if normalized.len() > 1 && has_empty_bucket_rows { + unmapped_empty_bucket = true; + warnings.push("analysis empty server bucket kept for multi-server install".to_string()); + } + } + + let needs_migration = library_total > 0 || analysis_total > 0; + let can_run = !normalized.is_empty(); + if needs_migration && !can_run { + warnings.push("no server mappings available".to_string()); + } + let has_skipped_unknown_server_rows = + library_skipped_unknown_rows > 0 || analysis_skipped_unknown_rows > 0; + if has_skipped_unknown_server_rows { + warnings.push("rows for removed servers were skipped".to_string()); + } + + Ok(MigrationInspectReport { + needs_migration, + has_skipped_unknown_server_rows, + can_run, + warnings, + unmapped_empty_bucket, + library: MigrationScopeInspect { + total_legacy_rows: library_total, + skipped_unknown_server_rows: library_skipped_unknown_rows, + tables: library_tables, + }, + analysis: MigrationScopeInspect { + total_legacy_rows: analysis_total, + skipped_unknown_server_rows: analysis_skipped_unknown_rows, + tables: analysis_tables, + }, + mappings: normalized, + }) +} + +fn run_internal(app: &AppHandle, mappings: Vec) -> Result { + let inspect = inspect_internal(app, mappings)?; + if !inspect.needs_migration { + return Ok(MigrationRunResult { + library: MigrationRunScope { + imported_rows: 0, + source_rows: 0, + skipped_unknown_server_rows: inspect.library.skipped_unknown_server_rows, + }, + analysis: MigrationRunScope { + imported_rows: 0, + source_rows: 0, + skipped_unknown_server_rows: inspect.analysis.skipped_unknown_server_rows, + }, + has_skipped_unknown_server_rows: inspect.has_skipped_unknown_server_rows, + switched: false, + backup_removed: false, + }); + } + if !inspect.can_run { + return Err("migration requires at least one server mapping".to_string()); + } + + let paths = migration_paths(app)?; + let mappings = inspect.mappings; + let single_mapping = if mappings.len() == 1 { + Some(mappings[0].index_key.clone()) + } else { + None + }; + + emit_progress( + app, + "library", + "prepare", + 0, + LIBRARY_TABLES.len() as u64, + )?; + let (library_source_rows, library_imported_rows, library_skipped_unknown_rows) = + run_library_import(app, &paths, &mappings)?; + let (analysis_source_rows, analysis_imported_rows, analysis_skipped_unknown_rows) = + run_analysis_import(app, &paths, &mappings, single_mapping.as_deref())?; + + let mut backup_removed = false; + let mut library_backup: Option = None; + let mut analysis_backup: Option = None; + + if paths.library_v2.exists() { + if let Some(runtime) = app.try_state::() { + library_backup = runtime + .store + .swap_database_file(&paths.library_active, &paths.library_v2)?; + } else { + library_backup = Some(switch_file(&paths.library_active, &paths.library_v2)?); + } + } + if paths.analysis_v2.exists() { + if let Some(cache) = app.try_state::() { + analysis_backup = cache.swap_database_file(&paths.analysis_active, &paths.analysis_v2)?; + } else { + analysis_backup = Some(switch_file(&paths.analysis_active, &paths.analysis_v2)?); + } + } + let switched = library_backup.is_some() || analysis_backup.is_some(); + + if let Err(err) = health_check(&paths.library_active, &paths.analysis_active) { + if let Some(ref backup) = library_backup { + if let Some(runtime) = app.try_state::() { + let _ = runtime + .store + .restore_database_backup(backup, &paths.library_active); + } else { + let _ = restore_backup(backup, &paths.library_active); + } + } + if let Some(ref backup) = analysis_backup { + if let Some(cache) = app.try_state::() { + let _ = cache.restore_database_backup(backup, &paths.analysis_active); + } else { + let _ = restore_backup(backup, &paths.analysis_active); + } + } + return Err(err); + } + + if let Some(backup) = library_backup { + remove_db_with_sidecars(&backup)?; + backup_removed = true; + } + if let Some(backup) = analysis_backup { + remove_db_with_sidecars(&backup)?; + backup_removed = true; + } + + Ok(MigrationRunResult { + library: MigrationRunScope { + imported_rows: library_imported_rows, + source_rows: library_source_rows, + skipped_unknown_server_rows: library_skipped_unknown_rows, + }, + analysis: MigrationRunScope { + imported_rows: analysis_imported_rows, + source_rows: analysis_source_rows, + skipped_unknown_server_rows: analysis_skipped_unknown_rows, + }, + has_skipped_unknown_server_rows: library_skipped_unknown_rows > 0 || analysis_skipped_unknown_rows > 0, + switched, + backup_removed, + }) +} + +fn run_library_import( + app: &AppHandle, + paths: &MigrationPaths, + mappings: &[ServerIndexMapping], +) -> Result<(u64, u64, u64), String> { + if !paths.library_active.exists() { + return Ok((0, 0, 0)); + } + remove_db_with_sidecars(&paths.library_v2).ok(); + vacuum_copy(&paths.library_active, &paths.library_v2)?; + + let source = open_readonly(&paths.library_active)?; + let dest = Connection::open(&paths.library_v2).map_err(|e| e.to_string())?; + let legacy_ids: Vec = mappings.iter().map(|m| m.legacy_id.clone()).collect(); + let index_keys: Vec = mappings.iter().map(|m| m.index_key.clone()).collect(); + let total = LIBRARY_TABLES.len() as u64; + let mut done = 0_u64; + with_foreign_keys_disabled(&dest, || { + for table in LIBRARY_TABLES { + purge_unknown_rows(&dest, table, &legacy_ids, &index_keys)?; + for mapping in mappings { + dest.execute( + &format!("UPDATE OR REPLACE {table} SET server_id = ?2 WHERE server_id = ?1"), + [&mapping.legacy_id, &mapping.index_key], + ) + .map_err(|e| e.to_string())?; + } + done = done.saturating_add(1); + emit_progress(app, "library", table, done, total)?; + } + Ok(()) + })?; + let source_rows = sum_table_rows(&source, LIBRARY_TABLES)?; + let imported_rows = sum_table_rows(&dest, LIBRARY_TABLES)?; + let skipped_unknown_server_rows = sum_unknown_rows(&source, LIBRARY_TABLES, &legacy_ids, &index_keys)?; + Ok((source_rows, imported_rows, skipped_unknown_server_rows)) +} + +fn run_analysis_import( + app: &AppHandle, + paths: &MigrationPaths, + mappings: &[ServerIndexMapping], + single_mapping: Option<&str>, +) -> Result<(u64, u64, u64), String> { + if !paths.analysis_active.exists() { + return Ok((0, 0, 0)); + } + remove_db_with_sidecars(&paths.analysis_v2).ok(); + vacuum_copy(&paths.analysis_active, &paths.analysis_v2)?; + + let source = open_readonly(&paths.analysis_active)?; + let dest = Connection::open(&paths.analysis_v2).map_err(|e| e.to_string())?; + let legacy_ids: Vec = mappings.iter().map(|m| m.legacy_id.clone()).collect(); + let index_keys: Vec = mappings.iter().map(|m| m.index_key.clone()).collect(); + let total = ANALYSIS_TABLES.len() as u64; + let mut done = 0_u64; + with_foreign_keys_disabled(&dest, || { + for table in ANALYSIS_TABLES { + purge_unknown_rows(&dest, table, &legacy_ids, &index_keys)?; + for mapping in mappings { + dest.execute( + &format!("UPDATE OR REPLACE {table} SET server_id = ?2 WHERE server_id = ?1"), + [&mapping.legacy_id, &mapping.index_key], + ) + .map_err(|e| e.to_string())?; + } + if let Some(index_key) = single_mapping { + dest.execute( + &format!("UPDATE OR REPLACE {table} SET server_id = ?2 WHERE server_id = ?1"), + ["", index_key], + ) + .map_err(|e| e.to_string())?; + } + done = done.saturating_add(1); + emit_progress(app, "analysis", table, done, total)?; + } + Ok(()) + })?; + let source_rows = sum_table_rows(&source, ANALYSIS_TABLES)?; + let imported_rows = sum_table_rows(&dest, ANALYSIS_TABLES)?; + let skipped_unknown_server_rows = sum_unknown_rows(&source, ANALYSIS_TABLES, &legacy_ids, &index_keys)?; + Ok((source_rows, imported_rows, skipped_unknown_server_rows)) +} + +fn normalize_mappings(mappings: Vec) -> Vec { + let mut out: Vec = Vec::new(); + for mapping in mappings { + let legacy_id = mapping.legacy_id.trim().to_string(); + let index_key = mapping.index_key.trim().to_string(); + if legacy_id.is_empty() || index_key.is_empty() { + continue; + } + if let Some(existing) = out.iter_mut().find(|v| v.legacy_id == legacy_id) { + existing.index_key = index_key; + } else { + out.push(ServerIndexMapping { + legacy_id, + index_key, + }); + } + } + out +} + +fn inspect_tables( + db_path: &Path, + tables: &[&str], + legacy_ids: &[String], + known_index_keys: &[String], +) -> Result<(HashMap, u64, u64), String> { + let mut counts = HashMap::new(); + if !db_path.exists() { + return Ok((counts, 0, 0)); + } + let conn = open_readonly(db_path)?; + let mut total = 0_u64; + let mut skipped_unknown_server_rows = 0_u64; + for table in tables { + let count = count_rows_in(&conn, table, legacy_ids)? as u64; + if count > 0 { + counts.insert((*table).to_string(), count); + total = total.saturating_add(count); + } + let unknown = + count_unknown_rows(&conn, table, legacy_ids, known_index_keys)? as u64; + skipped_unknown_server_rows = skipped_unknown_server_rows.saturating_add(unknown); + } + Ok((counts, total, skipped_unknown_server_rows)) +} + +fn count_rows_in(conn: &Connection, table: &str, values: &[String]) -> Result { + if values.is_empty() { + return Ok(0); + } + let placeholders = std::iter::repeat_n("?", values.len()).collect::>().join(","); + let sql = format!("SELECT COUNT(*) FROM {table} WHERE server_id IN ({placeholders})"); + conn.query_row(&sql, params_from_iter(values.iter()), |row| row.get(0)) + .map_err(|e| e.to_string()) +} + +fn count_rows_eq(conn: &Connection, table: &str, value: &str) -> Result { + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE server_id = ?1"), + [&value], + |row| row.get(0), + ) + .map_err(|e| e.to_string()) +} + +fn count_unknown_rows( + conn: &Connection, + table: &str, + known_legacy_ids: &[String], + known_index_keys: &[String], +) -> Result { + let known = known_server_ids(known_legacy_ids, known_index_keys); + if known.is_empty() { + return conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE server_id <> ''"), + [], + |row| row.get(0), + ) + .map_err(|e| e.to_string()); + } + let placeholders = std::iter::repeat_n("?", known.len()) + .collect::>() + .join(","); + let sql = + format!("SELECT COUNT(*) FROM {table} WHERE server_id <> '' AND server_id NOT IN ({placeholders})"); + conn.query_row(&sql, params_from_iter(known.iter()), |row| row.get(0)) + .map_err(|e| e.to_string()) +} + +fn purge_unknown_rows( + conn: &Connection, + table: &str, + known_legacy_ids: &[String], + known_index_keys: &[String], +) -> Result<(), String> { + let known = known_server_ids(known_legacy_ids, known_index_keys); + if known.is_empty() { + conn.execute(&format!("DELETE FROM {table} WHERE server_id <> ''"), []) + .map_err(|e| e.to_string())?; + return Ok(()); + } + let placeholders = std::iter::repeat_n("?", known.len()) + .collect::>() + .join(","); + let sql = + format!("DELETE FROM {table} WHERE server_id <> '' AND server_id NOT IN ({placeholders})"); + conn.execute(&sql, params_from_iter(known.iter())) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn known_server_ids(known_legacy_ids: &[String], known_index_keys: &[String]) -> Vec { + let mut known: Vec = Vec::new(); + known.extend(known_legacy_ids.iter().cloned()); + known.extend(known_index_keys.iter().cloned()); + known.sort(); + known.dedup(); + known +} + +fn sum_table_rows(conn: &Connection, tables: &[&str]) -> Result { + let mut total = 0_u64; + for table in tables { + let rows: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0)) + .map_err(|e| e.to_string())?; + total = total.saturating_add(rows.max(0) as u64); + } + Ok(total) +} + +fn sum_unknown_rows( + conn: &Connection, + tables: &[&str], + known_legacy_ids: &[String], + known_index_keys: &[String], +) -> Result { + let mut total = 0_u64; + for table in tables { + let rows = count_unknown_rows(conn, table, known_legacy_ids, known_index_keys)?; + total = total.saturating_add(rows.max(0) as u64); + } + Ok(total) +} + +fn with_foreign_keys_disabled( + conn: &Connection, + operation: impl FnOnce() -> Result, +) -> Result { + conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;") + .map_err(|e| e.to_string())?; + let result = operation(); + match result { + Ok(value) => { + if let Err(err) = conn + .execute_batch("COMMIT; PRAGMA foreign_keys = ON;") + .map_err(|e| e.to_string()) + { + let _ = conn.execute_batch("ROLLBACK; PRAGMA foreign_keys = ON;"); + return Err(err); + } + ensure_foreign_keys_clean(conn)?; + Ok(value) + } + Err(err) => { + let _ = conn.execute_batch("ROLLBACK; PRAGMA foreign_keys = ON;"); + Err(err) + } + } +} + +fn ensure_foreign_keys_clean(conn: &Connection) -> Result<(), String> { + let mut stmt = conn + .prepare("PRAGMA foreign_key_check") + .map_err(|e| e.to_string())?; + let mut rows = stmt.query([]).map_err(|e| e.to_string())?; + if let Some(row) = rows.next().map_err(|e| e.to_string())? { + let table: String = row.get(0).map_err(|e| e.to_string())?; + let rowid: i64 = row.get(1).map_err(|e| e.to_string())?; + let parent: String = row.get(2).map_err(|e| e.to_string())?; + let fkid: i64 = row.get(3).map_err(|e| e.to_string())?; + return Err(format!( + "foreign key check failed table={table} rowid={rowid} parent={parent} fkid={fkid}" + )); + } + Ok(()) +} + +fn emit_progress(app: &AppHandle, stage: &str, table: &str, done: u64, total: u64) -> Result<(), String> { + app.emit( + "migration:progress", + MigrationProgressEvent { + stage: stage.to_string(), + table: table.to_string(), + done, + total, + }, + ) + .map_err(|e| e.to_string()) +} + +fn open_readonly(path: &Path) -> Result { + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| e.to_string()) +} + +fn vacuum_copy(source: &Path, destination: &Path) -> Result<(), String> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let conn = Connection::open(source).map_err(|e| e.to_string())?; + let sql = format!( + "VACUUM INTO '{}';", + destination.to_string_lossy().replace('\'', "''") + ); + conn.execute_batch(&sql).map_err(|e| e.to_string()) +} + +fn switch_file(active: &Path, destination: &Path) -> Result { + let backup = active.with_file_name(format!( + "{}.backup-pre-indexkey", + active + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("db.sqlite") + )); + remove_db_with_sidecars(&backup).ok(); + if active.exists() { + fs::rename(active, &backup).map_err(|e| e.to_string())?; + } + fs::rename(destination, active).map_err(|e| e.to_string())?; + Ok(backup) +} + +fn restore_backup(backup: &Path, active: &Path) -> Result<(), String> { + if active.exists() { + fs::remove_file(active).map_err(|e| e.to_string())?; + } + if backup.exists() { + fs::rename(backup, active).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn health_check(library_active: &Path, analysis_active: &Path) -> Result<(), String> { + if library_active.exists() { + let conn = open_readonly(library_active)?; + conn.query_row("SELECT COUNT(*) FROM track", [], |_row| Ok(())) + .map_err(|e| e.to_string())?; + } + if analysis_active.exists() { + let conn = open_readonly(analysis_active)?; + conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(())) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn remove_db_with_sidecars(path: &Path) -> Result<(), String> { + remove_if_exists(path)?; + let wal = PathBuf::from(format!("{}-wal", path.to_string_lossy())); + let shm = PathBuf::from(format!("{}-shm", path.to_string_lossy())); + remove_if_exists(&wal)?; + remove_if_exists(&shm)?; + Ok(()) +} + +fn remove_if_exists(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + Ok(()) +} + +struct MigrationPaths { + library_active: PathBuf, + library_v2: PathBuf, + analysis_active: PathBuf, + analysis_v2: PathBuf, +} + +fn migration_paths(app: &AppHandle) -> Result { + let base = app.path().app_data_dir().map_err(|e| e.to_string())?; + let library_dir = base.join("databases").join("library"); + let analysis_dir = base.join("databases").join("analysis"); + fs::create_dir_all(&library_dir).map_err(|e| e.to_string())?; + fs::create_dir_all(&analysis_dir).map_err(|e| e.to_string())?; + Ok(MigrationPaths { + library_active: library_dir.join("library.sqlite"), + library_v2: library_dir.join("library-v2.sqlite"), + analysis_active: analysis_dir.join("audio-analysis.sqlite"), + analysis_v2: analysis_dir.join("analysis-v2.sqlite"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspect_reports_skipped_unknown_rows() { + let conn = Connection::open_in_memory().expect("in-memory sqlite"); + conn.execute_batch("CREATE TABLE track (server_id TEXT NOT NULL);") + .expect("create table"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["legacy-a"]) + .expect("insert known legacy"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["removed-x"]) + .expect("insert unknown"); + + let known_legacy_ids = vec!["legacy-a".to_string()]; + let known_index_keys = vec!["idx-a".to_string()]; + let unknown = count_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys) + .expect("unknown count"); + assert_eq!(unknown, 1); + } + + #[test] + fn run_reports_skipped_unknown_rows_without_failure() { + let conn = Connection::open_in_memory().expect("in-memory sqlite"); + conn.execute_batch("CREATE TABLE analysis_track (server_id TEXT NOT NULL);") + .expect("create table"); + conn.execute("INSERT INTO analysis_track(server_id) VALUES (?1)", ["legacy-a"]) + .expect("insert known legacy"); + conn.execute("INSERT INTO analysis_track(server_id) VALUES (?1)", ["removed-x"]) + .expect("insert unknown"); + + let known_legacy_ids = vec!["legacy-a".to_string()]; + let known_index_keys = vec!["idx-a".to_string()]; + let skipped = sum_unknown_rows(&conn, &["analysis_track"], &known_legacy_ids, &known_index_keys) + .expect("sum unknown rows"); + assert_eq!(skipped, 1); + } + + #[test] + fn needs_migration_false_when_only_unknown_rows_present() { + let conn = Connection::open_in_memory().expect("in-memory sqlite"); + conn.execute_batch("CREATE TABLE track (server_id TEXT NOT NULL);") + .expect("create table"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["removed-x"]) + .expect("insert unknown"); + + let known_legacy_ids = vec!["legacy-a".to_string()]; + let known_index_keys = vec!["idx-a".to_string()]; + let legacy = count_rows_in(&conn, "track", &known_legacy_ids).expect("legacy count"); + let unknown = count_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys) + .expect("unknown count"); + assert_eq!(legacy, 0); + assert_eq!(unknown, 1); + } + + #[test] + fn purge_unknown_rows_removes_only_removed_servers() { + let conn = Connection::open_in_memory().expect("in-memory sqlite"); + conn.execute_batch("CREATE TABLE track (server_id TEXT NOT NULL);") + .expect("create table"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["legacy-a"]) + .expect("insert legacy"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["idx-a"]) + .expect("insert index key"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", [""]) + .expect("insert empty bucket"); + conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["removed-x"]) + .expect("insert removed server"); + + let known_legacy_ids = vec!["legacy-a".to_string()]; + let known_index_keys = vec!["idx-a".to_string()]; + purge_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys) + .expect("purge unknown rows"); + + let remaining: i64 = conn + .query_row("SELECT COUNT(*) FROM track", [], |row| row.get(0)) + .expect("count remaining"); + let removed_left: i64 = conn + .query_row( + "SELECT COUNT(*) FROM track WHERE server_id = 'removed-x'", + [], + |row| row.get(0), + ) + .expect("count removed server rows"); + assert_eq!(remaining, 3); + assert_eq!(removed_left, 0); + } +} diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs index eccf7325..79017237 100644 --- a/src-tauri/src/lib_commands/app_api/mod.rs +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -1,10 +1,15 @@ +mod backup; mod cli_bridge; mod core; mod integration; +mod migration; mod perf; pub(crate) mod platform; // Tauri commands re-exported for the lib.rs invoke_handler. +pub(crate) use backup::{ + backup_export_full, backup_export_library_db, backup_import_full, backup_import_library_db, +}; pub(crate) use cli_bridge::{ cli_publish_library_list, cli_publish_player_snapshot, cli_publish_search_results, cli_publish_server_list, @@ -27,6 +32,7 @@ pub(crate) use integration::{ check_dir_accessible, mpris_set_metadata, mpris_set_playback, register_global_shortcut, unregister_global_shortcut, }; +pub(crate) use migration::{migration_inspect, migration_run}; // Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown, // and analysis admin commands now live in their domain crates. invoke_handler! diff --git a/src/api/analysis.ts b/src/api/analysis.ts new file mode 100644 index 00000000..9dc46293 --- /dev/null +++ b/src/api/analysis.ts @@ -0,0 +1,121 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; +import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey'; + +export interface AnalysisBackfillQueueStatsDto { + queued: number; + inProgressCount: number; + inProgressTrackId: string | null; +} + +export interface AnalysisPipelineQueueStatsDto { + pipelineWorkers: number; + httpQueued: number; + httpQueuedHigh: number; + httpQueuedMiddle: number; + httpQueuedLow: number; + httpDownloadActive: number; + httpDownloadActiveHigh: number; + httpDownloadActiveMiddle: number; + httpDownloadActiveLow: number; + cpuQueued: number; + cpuQueuedHigh: number; + cpuQueuedMiddle: number; + cpuQueuedLow: number; + cpuDecodeActive: number; + cpuDecodeActiveHigh: number; + cpuDecodeActiveMiddle: number; + cpuDecodeActiveLow: number; +} + +export interface LibraryAnalysisBackfillBatchDto { + trackIds: string[]; + nextCursor: string | null; + exhausted: boolean; +} + +export interface LibraryAnalysisProgressDto { + totalTracks: number; + pendingTracks: number; + doneTracks: number; +} + +export interface AnalysisDeleteServerReportDto { + analysisTracks: number; + waveforms: number; + loudness: number; +} + +export const LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE = 20; + +function serverIndexKeyForId(serverId: string): string { + const server = useAuthStore.getState().servers.find(s => s.id === serverId); + if (!server) return serverId; + return serverIndexKeyFromUrl(server.url) || serverId; +} + +export function analysisGetBackfillQueueStats(): Promise { + return invoke('analysis_get_backfill_queue_stats'); +} + +export function analysisGetPipelineQueueStats(): Promise { + return invoke('analysis_get_pipeline_queue_stats'); +} + +export function libraryAnalysisBackfillBatch( + serverId: string, + cursor?: string | null, + limit = LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE, +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_analysis_backfill_batch', { + serverId: indexKey, + cursor: cursor ?? null, + limit, + }); +} + +export function libraryAnalysisProgress( + serverId: string, +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_analysis_progress', { serverId: indexKey }); +} + +export function analysisDeleteAllForServer( + serverId: string, +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('analysis_delete_all_for_server', { serverId: indexKey }); +} + +export type AnalysisBackfillPriority = 'high' | 'middle' | 'low'; + +export function analysisSetPipelineParallelism(workers: number): Promise { + return invoke('analysis_set_pipeline_parallelism', { workers }); +} + +export type AnalysisPriorityHintDto = { + serverId: string; + trackId: string; +}; + +export function analysisSetPlaybackPriorityHints( + middleTrackRefs: AnalysisPriorityHintDto[], +): Promise { + const remapped = middleTrackRefs.map(ref => ({ + ...ref, + serverId: serverIndexKeyForId(ref.serverId), + })); + return invoke('analysis_set_playback_priority_hints', { middleTrackRefs: remapped }); +} + +export function analysisEnqueueSeedFromUrl( + trackId: string, + url: string, + serverId: string, + priority: AnalysisBackfillPriority = 'low', +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId: indexKey, priority }); +} diff --git a/src/api/library.ts b/src/api/library.ts index c5d79e33..2e4caa45 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -8,6 +8,9 @@ import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { useAuthStore } from '../store/authStore'; +import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey'; +import { resolveServerIdForIndexKey } from '../utils/server/serverLookup'; // ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─ @@ -258,13 +261,37 @@ export interface LibraryCrossServerSearchResponse { serversSearched: string[]; } +function serverIndexKeyForId(serverId: string): string { + const server = useAuthStore.getState().servers.find(s => s.id === serverId); + if (!server) return serverId; + return serverIndexKeyFromUrl(server.url) || serverId; +} + +function mapServerIdFromIndexKey(serverId: string, fallback?: string): string { + if (fallback) return fallback; + return resolveServerIdForIndexKey(serverId); +} + +function mapTracksServerId( + tracks: LibraryTrackDto[], + fallbackServerId?: string, +): LibraryTrackDto[] { + if (tracks.length === 0) return tracks; + return tracks.map(track => ({ + ...track, + serverId: mapServerIdFromIndexKey(track.serverId, fallbackServerId), + })); +} + // ── Read commands (PR-5a) ───────────────────────────────────────────── export function libraryGetStatus( serverId: string, libraryScope?: string, ): Promise { - return invoke('library_get_status', { serverId, libraryScope }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_get_status', { serverId: indexKey, libraryScope }) + .then(status => ({ ...status, serverId })); } export function librarySearch( @@ -272,13 +299,17 @@ export function librarySearch( query: string, options?: { limit?: number; offset?: number; libraryScope?: string }, ): Promise { + const indexKey = serverIndexKeyForId(serverId); return invoke('library_search', { - serverId, + serverId: indexKey, query, limit: options?.limit, offset: options?.offset, libraryScope: options?.libraryScope, - }); + }).then(envelope => ({ + ...envelope, + tracks: mapTracksServerId(envelope.tracks, serverId), + })); } /** @@ -289,7 +320,21 @@ export function librarySearch( export function libraryAdvancedSearch( request: LibraryAdvancedSearchRequest, ): Promise { - return invoke('library_advanced_search', { request }); + const indexKey = serverIndexKeyForId(request.serverId); + return invoke('library_advanced_search', { + request: { ...request, serverId: indexKey }, + }).then(response => ({ + ...response, + artists: response.artists.map(artist => ({ + ...artist, + serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId), + })), + albums: response.albums.map(album => ({ + ...album, + serverId: mapServerIdFromIndexKey(album.serverId, request.serverId), + })), + tracks: mapTracksServerId(response.tracks, request.serverId), + })); } export interface LibraryLiveSearchResponse { @@ -313,7 +358,21 @@ export interface LibraryLiveSearchRequest { } export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise { - return invoke('library_live_search', { request }); + const indexKey = serverIndexKeyForId(request.serverId); + return invoke('library_live_search', { + request: { ...request, serverId: indexKey }, + }).then(response => ({ + ...response, + artists: response.artists.map(artist => ({ + ...artist, + serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId), + })), + albums: response.albums.map(album => ({ + ...album, + serverId: mapServerIdFromIndexKey(album.serverId, request.serverId), + })), + tracks: mapTracksServerId(response.tracks, request.serverId), + })); } /** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */ @@ -322,25 +381,48 @@ export function librarySearchCrossServer(args: { limit?: number; servers?: string[]; }): Promise { - return invoke('library_search_cross_server', args); + const indexServers = args.servers?.map(serverIndexKeyForId); + return invoke('library_search_cross_server', { + ...args, + servers: indexServers, + }).then(response => ({ + ...response, + hits: mapTracksServerId(response.hits), + fuzzy: mapTracksServerId(response.fuzzy), + serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)), + })); } export function libraryGetTrack( serverId: string, trackId: string, ): Promise { - return invoke('library_get_track', { serverId, trackId }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_get_track', { serverId: indexKey, trackId }) + .then(track => (track ? { ...track, serverId } : track)); } export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { - return invoke('library_get_tracks_batch', { refs }); + const indexKeyMap = new Map(); + const remapped = refs.map(ref => { + const indexKey = serverIndexKeyForId(ref.serverId); + if (!indexKeyMap.has(indexKey)) indexKeyMap.set(indexKey, ref.serverId); + return { ...ref, serverId: indexKey }; + }); + return invoke('library_get_tracks_batch', { refs: remapped }) + .then(tracks => tracks.map(track => ({ + ...track, + serverId: mapServerIdFromIndexKey(track.serverId, indexKeyMap.get(track.serverId)), + }))); } export function libraryGetTracksByAlbum( serverId: string, albumId: string, ): Promise { - return invoke('library_get_tracks_by_album', { serverId, albumId }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_get_tracks_by_album', { serverId: indexKey, albumId }) + .then(tracks => mapTracksServerId(tracks, serverId)); } export function libraryGetArtifact( @@ -349,14 +431,15 @@ export function libraryGetArtifact( artifactKind: string, options?: { sourceKind?: string; sourceId?: string; format?: string }, ): Promise { + const indexKey = serverIndexKeyForId(serverId); return invoke('library_get_artifact', { - serverId, + serverId: indexKey, trackId, artifactKind, sourceKind: options?.sourceKind, sourceId: options?.sourceId, format: options?.format, - }); + }).then(artifact => (artifact ? { ...artifact, serverId } : artifact)); } export function libraryGetFacts( @@ -364,14 +447,18 @@ export function libraryGetFacts( trackId: string, factKinds?: string[], ): Promise { - return invoke('library_get_facts', { serverId, trackId, factKinds }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_get_facts', { serverId: indexKey, trackId, factKinds }) + .then(facts => facts.map(fact => ({ ...fact, serverId }))); } export function libraryGetOfflinePath( serverId: string, trackId: string, ): Promise { - return invoke('library_get_offline_path', { serverId, trackId }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_get_offline_path', { serverId: indexKey, trackId }) + .then(path => ({ ...path, serverId })); } // ── Session + lifecycle (PR-5b) ─────────────────────────────────────── @@ -383,11 +470,13 @@ export function librarySyncBindSession(args: { password: string; libraryScope?: string; }): Promise { - return invoke('library_sync_bind_session', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_sync_bind_session', { ...args, serverId: indexKey }); } export function librarySyncClearSession(serverId: string): Promise { - return invoke('library_sync_clear_session', { serverId }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_sync_clear_session', { serverId: indexKey }); } export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active'; @@ -407,7 +496,9 @@ export function librarySyncStart(args: { mode: SyncMode; libraryScope?: string; }): Promise { - return invoke('library_sync_start', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_sync_start', { ...args, serverId: indexKey }) + .then(job => ({ ...job, serverId: args.serverId })); } /** Forced full-budget tombstone delta — Settings → «Verify integrity». */ @@ -415,7 +506,9 @@ export function librarySyncVerifyIntegrity(args: { serverId: string; libraryScope?: string; }): Promise { - return invoke('library_sync_verify_integrity', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_sync_verify_integrity', { ...args, serverId: indexKey }) + .then(job => ({ ...job, serverId: args.serverId })); } export function librarySyncCancel(jobId?: string): Promise { @@ -435,7 +528,8 @@ export function libraryPatchTrack(args: { contentHash?: string | null; }; }): Promise { - return invoke('library_patch_track', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_patch_track', { ...args, serverId: indexKey }); } export function libraryPutArtifact(args: { @@ -443,7 +537,8 @@ export function libraryPutArtifact(args: { trackId: string; artifact: ArtifactInputDto; }): Promise { - return invoke('library_put_artifact', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_put_artifact', { ...args, serverId: indexKey }); } export function libraryPutFact(args: { @@ -451,7 +546,8 @@ export function libraryPutFact(args: { trackId: string; fact: FactInputDto; }): Promise { - return invoke('library_put_fact', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_put_fact', { ...args, serverId: indexKey }); } export function libraryPurgeServer(args: { @@ -459,11 +555,13 @@ export function libraryPurgeServer(args: { includeAnalysis?: boolean; includeOffline?: boolean; }): Promise { - return invoke('library_purge_server', args); + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_purge_server', { ...args, serverId: indexKey }); } export function libraryDeleteServerData(serverId: string): Promise { - return invoke('library_delete_server_data', { serverId }); + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_delete_server_data', { serverId: indexKey }); } // ── Player stats (local listening history) ──────────────────────────── @@ -532,7 +630,8 @@ export type PlaySessionRecentDay = { }; export function libraryRecordPlaySession(input: PlaySessionInput): Promise { - return invoke('library_record_play_session', { input }); + const indexKey = serverIndexKeyForId(input.serverId); + return invoke('library_record_play_session', { input: { ...input, serverId: indexKey } }); } export function libraryGetPlayerStatsYearSummary(year: number): Promise { @@ -544,7 +643,14 @@ export function libraryGetPlayerStatsHeatmap(year: number): Promise { - return invoke('library_get_player_stats_day_detail', { dateIso }); + return invoke('library_get_player_stats_day_detail', { dateIso }) + .then(detail => ({ + ...detail, + tracks: detail.tracks.map(track => ({ + ...track, + serverId: mapServerIdFromIndexKey(track.serverId), + })), + })); } export function libraryGetPlayerStatsYearBounds(): Promise { diff --git a/src/api/migration.ts b/src/api/migration.ts new file mode 100644 index 00000000..ec11687f --- /dev/null +++ b/src/api/migration.ts @@ -0,0 +1,56 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface ServerIndexMapping { + legacyId: string; + indexKey: string; +} + +export interface MigrationInspectScope { + totalLegacyRows: number; + skippedUnknownServerRows: number; + tables: Record; +} + +export interface MigrationInspectReport { + needsMigration: boolean; + hasSkippedUnknownServerRows: boolean; + canRun: boolean; + warnings: string[]; + unmappedEmptyBucket: boolean; + library: MigrationInspectScope; + analysis: MigrationInspectScope; + mappings: ServerIndexMapping[]; +} + +export interface MigrationProgressEvent { + stage: string; + table: string; + done: number; + total: number; +} + +export interface MigrationRunScope { + importedRows: number; + sourceRows: number; + skippedUnknownServerRows: number; +} + +export interface MigrationRunResult { + library: MigrationRunScope; + analysis: MigrationRunScope; + hasSkippedUnknownServerRows: boolean; + switched: boolean; + backupRemoved: boolean; +} + +export function migrationInspect( + mappings: ServerIndexMapping[], +): Promise { + return invoke('migration_inspect', { mappings }); +} + +export function migrationRun( + mappings: ServerIndexMapping[], +): Promise { + return invoke('migration_run', { mappings }); +} diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index cf27d9ae..e69ff744 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -3,6 +3,7 @@ import md5 from 'md5'; import { version } from '../../package.json'; import { useAuthStore } from '../store/authStore'; import type { ServerProfile } from '../store/authStoreTypes'; +import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup'; export const SUBSONIC_CLIENT = `psysonic/${version}`; @@ -53,7 +54,7 @@ export function getClient() { } export function getServerById(serverId: string): ServerProfile | undefined { - return useAuthStore.getState().servers.find(s => s.id === serverId); + return findServerByIdOrIndexKey(serverId); } /** Subsonic REST call against an explicit saved server (not necessarily the active one). */ @@ -95,7 +96,8 @@ export function libraryFilterParams(): Record { /** 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]; + const resolved = resolveServerIdForIndexKey(serverId); + const f = useAuthStore.getState().musicLibraryFilterByServer[resolved]; if (f === undefined || f === 'all') return undefined; return f; } diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index 9de87e05..a4268e6c 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -1,5 +1,6 @@ import md5 from 'md5'; import { useAuthStore } from '../store/authStore'; +import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams { @@ -39,7 +40,7 @@ function streamUrlFromProfile( } export function buildStreamUrlForServer(serverId: string, id: string): string { - const server = useAuthStore.getState().servers.find(s => s.id === serverId); + const server = findServerByIdOrIndexKey(serverId); if (!server) return buildStreamUrl(id); return streamUrlFromProfile(server.url, server.username, server.password, id); } diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 6ec9d16b..09d7c174 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -132,6 +132,7 @@ export function AppShell() { usePlaybackRateStore.getState().syncToRust(); }, []); + useEffect(() => { getCurrentWebview().setZoom(uiScale).catch(() => { /* setZoom may fail on platforms where the capability is unavailable; diff --git a/src/app/BlockingMigrationGate.tsx b/src/app/BlockingMigrationGate.tsx new file mode 100644 index 00000000..57cd72ef --- /dev/null +++ b/src/app/BlockingMigrationGate.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react'; +import { retryServerIndexMigration } from '../hooks/useMigrationOrchestrator'; +import { useMigrationStore } from '../store/migrationStore'; + +function MigrationModal() { + const phase = useMigrationStore(s => s.phase); + const progress = useMigrationStore(s => s.progress); + const inspect = useMigrationStore(s => s.inspect); + const error = useMigrationStore(s => s.lastError); + + const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0); + return ( +
+
+ {phase === 'inspecting' && ( + <> +

Preparing data update…

+

Looking at your library and analysis cache…

+ + )} + {phase === 'running' && ( + <> +

Migrating data

+

+ {progress ? `${progress.stage} - ${progress.table}` : 'running'} +

+

+ {progress ? `${progress.done} / ${progress.total}` : 'working…'} +

+ {inspect?.hasSkippedUnknownServerRows ? ( +

+ Rows for removed servers were skipped and old backup DB will be removed after successful switch. +

+ ) : null} + + )} + {phase === 'error' && ( + <> +

Migration failed

+

{String(error ?? '').slice(0, 200)}

+
+ + +
+ + )} + {phase === 'completed' && ( + <> +

Update complete

+

{migratedRows} rows migrated

+ + )} +
+
+ ); +} + +export default function BlockingMigrationGate({ children }: { children: ReactNode }) { + const phase = useMigrationStore(s => s.phase); + const isBlocking = phase === 'inspecting' || phase === 'running' || phase === 'error'; + return ( + <> + {children} + {isBlocking ? : null} + + ); +} diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 0f4da500..e44b1963 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -17,10 +17,14 @@ import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; import { hydrateQueueFromIndex } from '../utils/library/queueRestore'; +import { useLibraryAnalysisBackfill } from '../hooks/useLibraryAnalysisBackfill'; +import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator'; import { IS_WINDOWS } from '../utils/platform'; import TauriEventBridge from './TauriEventBridge'; import AppShell from './AppShell'; +import BlockingMigrationGate from './BlockingMigrationGate'; import RequireAuth from './RequireAuth'; +import { useMigrationStore } from '../store/migrationStore'; const Login = lazy(() => import('../pages/Login')); @@ -45,12 +49,18 @@ export default function MainApp() { const activeServerId = useAuthStore(s => s.activeServerId); const serverIdsKey = useAuthStore(s => s.servers.map(srv => srv.id).join(',')); const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + const migrationPhase = useMigrationStore(s => s.phase); + const migrationReady = migrationPhase === 'completed'; + useMigrationOrchestrator(); useEffect(() => { + if (!migrationReady) return; void (async () => { await bootstrapAllIndexedServers(); void hydrateQueueFromIndex(); })(); - }, [activeServerId, serverIdsKey, masterEnabled]); + }, [activeServerId, serverIdsKey, masterEnabled, migrationReady]); + + useLibraryAnalysisBackfill(migrationReady); // Push playback state to mini window + handle control events. useEffect(() => { @@ -62,21 +72,24 @@ export default function MainApp() { // a hang workaround — skip here to avoid double-building. const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer); useEffect(() => { - if (IS_WINDOWS || !preloadMiniPlayer) return; + if (!migrationReady || IS_WINDOWS || !preloadMiniPlayer) return; invoke('preload_mini_player').catch(() => {}); - }, [preloadMiniPlayer]); + }, [preloadMiniPlayer, migrationReady]); useEffect(() => { + if (!migrationReady) return undefined; return initAudioListeners(); - }, []); + }, [migrationReady]); useEffect(() => { + if (!migrationReady) return undefined; return initHotCachePrefetch(); - }, []); + }, [migrationReady]); useEffect(() => { + if (!migrationReady) return; useGlobalShortcutsStore.getState().registerAll(); - }, []); + }, [migrationReady]); // ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ── useEffect(() => { @@ -128,26 +141,28 @@ export default function MainApp() { return ( - - - - - } /> - - - - - - } - /> - - - {exportPickerOpen && setExportPickerOpen(false)} />} - - + + + + + + } /> + + + + + + } + /> + + + {exportPickerOpen && setExportPickerOpen(false)} />} + + + ); diff --git a/src/components/FpsOverlay.tsx b/src/components/FpsOverlay.tsx index a742ddca..6a6a7828 100644 --- a/src/components/FpsOverlay.tsx +++ b/src/components/FpsOverlay.tsx @@ -1,13 +1,63 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis'; import { usePerfProbeFlag } from '../utils/perf/perfFlags'; +import { + formatPerfMs, + getAnalysisTracksPerMinute, + useAnalysisPerfLast, +} from '../utils/perf/analysisPerfStore'; +import { formatAnalysisPipelineQueueOverlay } from '../utils/perf/formatAnalysisQueueStats'; +import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener'; const SAMPLE_MS = 500; +const TPM_REFRESH_MS = 500; +const QUEUE_STATS_MS = 750; -/** FPS from rAF callbacks over sliding ~500ms windows; only runs when Performance Probe enables the overlay. */ +/** FPS + analysis throughput overlay (Performance Probe). */ export default function FpsOverlay() { const showFpsOverlay = usePerfProbeFlag('showFpsOverlay'); + const showAnalysisPerfOverlay = usePerfProbeFlag('showAnalysisPerfOverlay'); const [fps, setFps] = useState(0); + const [tpm, setTpm] = useState(0); + const [queueStats, setQueueStats] = useState(null); + const last = useAnalysisPerfLast(); + + useAnalysisPerfListener(showAnalysisPerfOverlay); + + useEffect(() => { + if (!showAnalysisPerfOverlay) { + setTpm(0); + return; + } + const refresh = () => setTpm(getAnalysisTracksPerMinute()); + refresh(); + const id = window.setInterval(refresh, TPM_REFRESH_MS); + return () => window.clearInterval(id); + }, [showAnalysisPerfOverlay, last?.at]); + + useEffect(() => { + if (!showAnalysisPerfOverlay) { + setQueueStats(null); + return; + } + let cancelled = false; + const refresh = () => { + void analysisGetPipelineQueueStats() + .then(stats => { + if (!cancelled) setQueueStats(stats); + }) + .catch(() => { + if (!cancelled) setQueueStats(null); + }); + }; + refresh(); + const id = window.setInterval(refresh, QUEUE_STATS_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [showAnalysisPerfOverlay]); useEffect(() => { if (!showFpsOverlay) { @@ -35,13 +85,50 @@ export default function FpsOverlay() { return () => cancelAnimationFrame(rafId); }, [showFpsOverlay]); - if (!showFpsOverlay) return null; + if (!showFpsOverlay && !showAnalysisPerfOverlay) return null; return createPortal( , document.body, ); diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index b16a3b4b..0790c80a 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -26,6 +26,7 @@ import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './Cached import { showToast } from '../utils/ui/toast'; import { useShareSearch } from '../hooks/useShareSearch'; import ShareSearchResults from './search/ShareSearchResults'; +import { resolveIndexKey } from '../utils/server/serverIndexKey'; type LiveSearchSource = 'local' | 'network'; @@ -98,13 +99,14 @@ export default function LiveSearch() { if (!indexEnabled || !serverId) return; let unlistenProgress: (() => void) | undefined; let unlistenIdle: (() => void) | undefined; + const indexKey = resolveIndexKey(serverId); void subscribeLibrarySyncIdle(payload => { - if (payload.serverId === serverId) void refreshLocalReady(); + if (payload.serverId === indexKey) void refreshLocalReady(); }).then(fn => { unlistenIdle = fn; }); void subscribeLibrarySyncProgress(p => { - if (p.serverId === serverId && p.kind === 'phase_changed') void refreshLocalReady(); + if (p.serverId === indexKey && p.kind === 'phase_changed') void refreshLocalReady(); }).then(fn => { unlistenProgress = fn; }); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cf8a24a3..39635992 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -132,7 +132,7 @@ export default function Sidebar({ isLoggedIn, pathname: location.pathname, }); - const { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates } = useSidebarPerfProbe(); + const { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates, analysisPerf } = useSidebarPerfProbe(); const perfFlags = usePerfProbeFlags(); @@ -260,6 +260,7 @@ export default function Sidebar({ perfFlags={perfFlags} perfCpu={perfCpu} perfDiagRates={perfDiagRates} + analysisPerf={analysisPerf} hotCacheEnabled={hotCacheEnabled} setHotCacheEnabled={setHotCacheEnabled} normalizationEngine={normalizationEngine} diff --git a/src/components/settings/AnalyticsStrategySection.tsx b/src/components/settings/AnalyticsStrategySection.tsx new file mode 100644 index 00000000..6d22d187 --- /dev/null +++ b/src/components/settings/AnalyticsStrategySection.tsx @@ -0,0 +1,371 @@ +import { AlertTriangle, BarChart3, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import SettingsSubSection from '../SettingsSubSection'; +import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; +import { useAuthStore } from '../../store/authStore'; +import { + analysisDeleteAllForServer, + libraryAnalysisProgress, + type LibraryAnalysisProgressDto, +} from '../../api/analysis'; +import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; +import { showToast } from '../../utils/ui/toast'; +import { + ANALYTICS_STRATEGIES, + ADVANCED_PARALLELISM_MAX, + ADVANCED_PARALLELISM_MIN, + type AnalyticsStrategy, +} from '../../utils/library/analysisStrategy'; + +type ClearTarget = { + serverId: string; + label: string; +}; + +export default function AnalyticsStrategySection() { + const { t } = useTranslation(); + const servers = useAuthStore(s => s.servers); + const { + strategyByServer, + advancedParallelismByServer, + setServerStrategy, + setServerAdvancedParallelism, + clearServerOverrides, + getStrategyForServer, + getAdvancedParallelismForServer, + } = useAnalysisStrategyStore(); + const [progressByServer, setProgressByServer] = useState>({}); + const [clearTarget, setClearTarget] = useState(null); + const [clearingServerId, setClearingServerId] = useState(null); + + const activeServerIds = useMemo( + () => new Set(servers.map(server => serverIndexKeyForProfile(server))), + [servers], + ); + const removedServerIds = useMemo(() => { + const known = new Set([ + ...Object.keys(strategyByServer), + ...Object.keys(advancedParallelismByServer), + ]); + return Array.from(known).filter(id => !activeServerIds.has(id)); + }, [strategyByServer, advancedParallelismByServer, activeServerIds]); + + const anyAggressive = useMemo(() => { + return servers.some(server => getStrategyForServer(server.id) === 'advanced'); + }, [servers, getStrategyForServer]); + + useEffect(() => { + if (servers.length === 0) return; + let cancelled = false; + const refresh = () => { + void Promise.all( + servers.map(server => { + const key = serverIndexKeyForProfile(server); + return libraryAnalysisProgress(server.id) + .then(progress => ({ key, progress })) + .catch(() => ({ key, progress: null })); + }), + ).then(results => { + if (cancelled) return; + setProgressByServer(prev => { + const next = { ...prev }; + results.forEach(({ key, progress }) => { + next[key] = progress; + }); + return next; + }); + }); + }; + refresh(); + const id = window.setInterval(refresh, 5000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [servers]); + + const progressLabel = (progress: LibraryAnalysisProgressDto | null) => { + if (!progress || progress.totalTracks <= 0) return null; + const done = progress.doneTracks; + const total = progress.totalTracks; + const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100))); + return t('settings.analyticsStrategyProgressValue', { + percent, + done: done.toLocaleString(), + total: total.toLocaleString(), + }); + }; + + const strategyLabel = (s: AnalyticsStrategy) => { + switch (s) { + case 'lazy': + return t('settings.analyticsStrategyLazy'); + case 'advanced': + return t('settings.analyticsStrategyAdvanced'); + } + }; + + const handleClearAnalysis = async () => { + if (!clearTarget) return; + setClearingServerId(clearTarget.serverId); + try { + await analysisDeleteAllForServer(clearTarget.serverId); + clearServerOverrides(clearTarget.serverId); + showToast(t('settings.analyticsStrategyClearSuccess'), 4000, 'success'); + } catch { + showToast(t('settings.analyticsStrategyClearError'), 5000, 'error'); + } finally { + setClearingServerId(null); + setClearTarget(null); + } + }; + + return ( + } + > +
+

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

+ +
+ + + + + + + + + + + + {servers.map(server => { + const strategy = getStrategyForServer(server.id); + const advancedParallelism = getAdvancedParallelismForServer(server.id); + const key = serverIndexKeyForProfile(server); + const progress = progressByServer[key] ?? null; + const label = serverListDisplayLabel(server, servers); + return ( + + + + + + + + ); + })} + + {removedServerIds.map(serverId => ( + + + + + + + + ))} + +
+ {t('settings.analyticsStrategyServerLabel')} + + {t('settings.analyticsStrategyLabel')} + + {t('settings.analyticsStrategyParallelismLabel')} + + {t('settings.analyticsStrategyProgressLabel')} + + {t('settings.analyticsStrategyActionsLabel')} +
+ {label} + +
+ {ANALYTICS_STRATEGIES.map(s => ( + + ))} +
+
+ {strategy === 'advanced' ? ( +
+ { + const value = parseInt(e.target.value, 10); + setServerAdvancedParallelism(server.id, value); + }} + style={{ flex: 1, minWidth: 80, maxWidth: 160 }} + aria-valuemin={ADVANCED_PARALLELISM_MIN} + aria-valuemax={ADVANCED_PARALLELISM_MAX} + aria-valuenow={advancedParallelism} + /> + + {t('settings.analyticsStrategyParallelismValue', { n: advancedParallelism })} + +
+ ) : ( + + )} +
+ {progressLabel(progress) ?? '—'} + + +
+
{serverId}
+
+ {t('settings.analyticsStrategyServerRemoved')} +
+
+ +
+
+ +
+
+ {t('settings.analyticsStrategyPriorityTitle')} +
+
    +
  • {t('settings.analyticsStrategyPriorityHigh')}
  • +
  • {t('settings.analyticsStrategyPriorityMiddle')}
  • +
  • {t('settings.analyticsStrategyPriorityLow')}
  • +
+
+ +
+
+ + {t('settings.analyticsStrategyLazy')} + + {' '} + {t('settings.analyticsStrategyLazyDesc')} +
+
+ + {t('settings.analyticsStrategyAdvanced')} + + {' '} + {t('settings.analyticsStrategyAdvancedDesc')} +
+
+ + {anyAggressive && ( +
+ + + {t('settings.analyticsStrategyAdvancedWarning')} + +
+ )} +
+ + {clearTarget && + createPortal( +
setClearTarget(null)} + role="dialog" + aria-modal="true" + style={{ alignItems: 'center', paddingTop: 0 }} + > +
e.stopPropagation()} + style={{ maxWidth: '420px' }} + > + +

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

+

+ {t('settings.analyticsStrategyClearDesc', { server: clearTarget.label })} +

+
+ + +
+
+
, + document.body, + )} +
+ ); +} diff --git a/src/components/settings/BackupSection.tsx b/src/components/settings/BackupSection.tsx index 47d46609..e7708878 100644 --- a/src/components/settings/BackupSection.tsx +++ b/src/components/settings/BackupSection.tsx @@ -1,41 +1,152 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Download, HardDrive, Upload } from 'lucide-react'; -import { exportBackup, importBackup } from '../../utils/export/backup'; +import { Clock3, Download, HardDrive, Upload } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { + exportBackupToPath, + importAnyBackupFromPath, + pickBackupExportPath, + pickBackupImportPath, +} from '../../utils/export/backup'; import { showToast } from '../../utils/ui/toast'; +type BackupMode = 'full' | 'library' | 'config'; +type BackupAction = 'export' | 'import'; + export function BackupSection() { const { t } = useTranslation(); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); + const [mode, setMode] = useState('full'); + const [busyAction, setBusyAction] = useState(null); + + const waitForPaint = async () => { + await new Promise(resolve => window.setTimeout(resolve, 0)); + }; + + const busy = exporting || importing; const handleExport = async () => { + const exportMode = mode; + const path = await pickBackupExportPath(exportMode); + if (!path) return; + setBusyAction('export'); setExporting(true); try { - const path = await exportBackup(); - if (path) showToast(t('settings.backupSuccess'), 3000, 'info'); + await waitForPaint(); + await exportBackupToPath(exportMode, path); + const successKey = exportMode === 'full' + ? 'settings.backupFullExportSuccess' + : exportMode === 'library' + ? 'settings.backupLibraryExportSuccess' + : 'settings.backupSuccess'; + showToast(t(successKey), 3000, 'info'); } catch (e) { console.error('Export failed', e); - showToast(t('settings.backupImportError'), 4000, 'error'); + const errorKey = mode === 'full' + ? 'settings.backupFullImportError' + : mode === 'library' + ? 'settings.backupLibraryImportError' + : 'settings.backupImportError'; + showToast(t(errorKey), 4000, 'error'); } finally { setExporting(false); + setBusyAction(null); } }; const handleImport = async () => { - if (!window.confirm(t('settings.backupImportConfirm'))) return; + if (!window.confirm(t('settings.backupImportAnyConfirm'))) return; + const path = await pickBackupImportPath(); + if (!path) return; + setBusyAction('import'); setImporting(true); try { - await importBackup(); - // importBackup reloads the page — this toast will briefly show before reload - showToast(t('settings.backupImportSuccess'), 3000, 'info'); + await waitForPaint(); + const importedKind = await importAnyBackupFromPath(path); + if (importedKind === 'full') { + showToast(t('settings.backupFullImportSuccess'), 3000, 'info'); + } else if (importedKind === 'databases') { + showToast(t('settings.backupLibraryImportSuccess'), 3000, 'info'); + } else if (importedKind === 'config') { + showToast(t('settings.backupImportSuccess'), 3000, 'info'); + } } catch (e) { console.error('Import failed', e); showToast(t('settings.backupImportError'), 4000, 'error'); + } finally { setImporting(false); + setBusyAction(null); } }; + const modeTitle = mode === 'full' + ? t('settings.backupModeFull') + : mode === 'library' + ? t('settings.backupModeLibrary') + : t('settings.backupModeConfig'); + const modeDesc = mode === 'full' + ? t('settings.backupFullDesc') + : mode === 'library' + ? t('settings.backupLibraryExportDesc') + : t('settings.backupExportDesc'); + const exportLabel = mode === 'full' + ? t('settings.backupFullExport') + : mode === 'library' + ? t('settings.backupLibraryExport') + : t('settings.backupExport'); + const importLabel = t('settings.backupImportAny'); + const overlayTitle = busyAction === 'import' + ? t('settings.backupOverlayImportTitle') + : t('settings.backupOverlayExportTitle'); + const overlayHint = t('settings.backupOverlayHint'); + const busyOverlay = busy && typeof document !== 'undefined' + ? createPortal( +
+
+
+ +
+
{overlayTitle}
+
{overlayHint}
+
+
, + document.body, + ) + : null; + return (
@@ -43,43 +154,51 @@ export function BackupSection() {

{t('settings.backupTitle')}

- {/* Export */}
-
+
+ {(['full', 'library', 'config'] as BackupMode[]).map(candidate => ( + + ))} +
+ +
-
{t('settings.backupExport')}
-
{t('settings.backupExportDesc')}
+
{modeTitle}
+
{modeDesc}
+
+ +
-
-
- - {/* Import */} -
-
-
-
{t('settings.backupImport')}
-
{t('settings.backupImportDesc')}
-
+ {busyOverlay}
); } diff --git a/src/components/settings/LibraryIndexSection.tsx b/src/components/settings/LibraryIndexSection.tsx deleted file mode 100644 index 01ba34e7..00000000 --- a/src/components/settings/LibraryIndexSection.tsx +++ /dev/null @@ -1,474 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { flushSync } from 'react-dom'; -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, waitForLibrarySyncIdle } 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 [excludingServerId, setExcludingServerId] = useState(null); - const [includingServerId, setIncludingServerId] = 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); - setExcludingServerId(null); - setIncludingServerId(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) => { - if (includingServerId || excludingServerId) return; - const srv = servers.find(s => s.id === serverId); - if (!srv) return; - flushSync(() => { - setIncludingServerId(serverId); - setServerSyncExcluded(serverId, false); - }); - try { - const result = await bootstrapIndexedServer(srv); - applyConnectionResults({ [serverId]: result }); - if (result === 'error') { - setServerSyncExcluded(serverId, true); - showToast(t('settings.libraryIndexBindError', { error: t('settings.libraryIndexStatusError') }), 5000, 'error'); - return; - } - try { - const fresh = await libraryGetStatus(serverId); - syncPhaseRef.current[serverId] = fresh.syncPhase; - setStatusByServer(prev => ({ ...prev, [serverId]: fresh })); - } catch { - /* status poll is best-effort */ - } - } catch (e) { - setServerSyncExcluded(serverId, true); - showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error'); - } finally { - setIncludingServerId(null); - } - }; - - const handleExcludeServer = async (serverId: string) => { - if (excludingServerId || includingServerId) return; - flushSync(() => setExcludingServerId(serverId)); - try { - const syncing = - busyServerId === serverId || - statusByServer[serverId]?.syncPhase === 'initial_sync' || - statusByServer[serverId]?.syncPhase === 'probing'; - if (syncing) { - try { - await librarySyncCancel(); - await waitForLibrarySyncIdle(serverId); - } catch { - /* best-effort — proceed with unbind */ - } - } - 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; - }); - setProgressByServer(prev => { - const next = { ...prev }; - delete next[serverId]; - return next; - }); - if (busyServerId === serverId) { - setBusyServerId(null); - } - } catch (e) { - showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error'); - } finally { - setExcludingServerId(null); - } - }; - - const handleCancel = async () => { - try { - await librarySyncCancel(); - } catch { - /* best-effort */ - } - }; - - const globalBusy = - bootstrapping || busyServerId != null || excludingServerId != null || includingServerId != 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 deleted file mode 100644 index 0bf613c4..00000000 --- a/src/components/settings/LibraryIndexServerRow.tsx +++ /dev/null @@ -1,155 +0,0 @@ -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; - including: boolean; - excluding: boolean; - actionsDisabled: boolean; - onFullSync: () => void; - onDeltaSync: () => void; - onVerify: () => void; - onExclude: () => void; -} - -export default function LibraryIndexServerRow({ - server, - allServers, - isActive, - status, - connection, - progressLabel, - busy, - including, - excluding, - 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')} - )} - {including && !busy && ( - {t('settings.libraryIndexIncludingServer')} - )} -
-
- {phaseLabel} -
-
-
- -
- - - - -
-
- ); -} diff --git a/src/components/settings/LibraryTab.tsx b/src/components/settings/LibraryTab.tsx index 5e9a7bb2..9e61ecea 100644 --- a/src/components/settings/LibraryTab.tsx +++ b/src/components/settings/LibraryTab.tsx @@ -5,19 +5,18 @@ 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'; +import AnalyticsStrategySection from './AnalyticsStrategySection'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; export function LibraryTab() { const { t } = useTranslation(); - const auth = useAuthStore(); const [newGenre, setNewGenre] = useState(''); + const auth = useAuthStore(); return ( <> - {/* Local library index (spec §7.3) */} - + {/* Random Mix Blacklist */} void; + onDeltaSync: () => void; + onVerify: () => void; + onCancel: () => void; +} + +export default function ServerLibraryIndexControls({ + status, + connection, + progressLabel, + busy, + actionsDisabled, + onFullSync, + onDeltaSync, + onVerify, + onCancel, +}: ServerLibraryIndexControlsProps) { + const { t } = useTranslation(); + + 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 ( +
+
+ {connection === 'offline' && ( + + + {t('settings.libraryIndexServerDeferred')} + + )} + {busy && ( + + {t('settings.libraryIndexServerSyncing')} + + )} + {phaseLabel} +
+
+ + + + {busy && ( + + )} +
+
+ ); +} diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index 907c65ba..4598c03e 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -7,12 +7,15 @@ import { useAuthStore } from '../../store/authStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library'; import { bootstrapIndexedServer } from '../../utils/library/librarySession'; +import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync'; +import ServerLibraryIndexControls from './ServerLibraryIndexControls'; import type { ServerProfile } from '../../store/authStoreTypes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { useDragDrop } from '../../contexts/DragDropContext'; import { type ServerMagicPayload } from '../../utils/server/serverMagicString'; import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; import { switchActiveServer } from '../../utils/server/switchActiveServer'; import { AddServerForm } from './AddServerForm'; import { ServerGripHandle } from './ServerGripHandle'; @@ -30,6 +33,7 @@ export function ServersTab({ const navigate = useNavigate(); const auth = useAuthStore(); const psyDragState = useDragDrop(); + const librarySync = useLibraryIndexSync(); const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(initialInvite != null); @@ -147,7 +151,6 @@ export function ServersTab({ const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary')); auth.removeServer(server.id); - useLibraryIndexStore.getState().setIndexEnabled(server.id, false); try { await librarySyncClearSession(server.id); if (purgeLibrary) { @@ -180,10 +183,8 @@ 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); - } + const added = useAuthStore.getState().servers.find(s => s.id === id); + if (added) void bootstrapIndexedServer(added); } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } @@ -349,6 +350,17 @@ export function ServersTab({
+ void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'full')} + onDeltaSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'delta')} + onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')} + onCancel={() => void librarySync.handleCancel()} + /> {showAudiomuseNavidromeServerSetting( auth.subsonicServerIdentityByServer[srv.id], auth.instantMixProbeByServer[srv.id], diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index d5fde32b..711a0032 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -50,7 +50,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' }, { tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' }, { tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' }, - { tab: 'library', titleKey: 'settings.libraryIndexTitle', keywords: 'local library index sync offline search sqlite background delta' }, + { tab: 'servers', titleKey: 'settings.servers', keywords: 'local library index sync resync verify integrity offline delta background sqlite search' }, + { tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' }, { tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' }, { tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' }, { tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' }, diff --git a/src/components/sidebar/SidebarPerfProbeModal.tsx b/src/components/sidebar/SidebarPerfProbeModal.tsx index f4011a4c..c07100a9 100644 --- a/src/components/sidebar/SidebarPerfProbeModal.tsx +++ b/src/components/sidebar/SidebarPerfProbeModal.tsx @@ -15,12 +15,21 @@ interface PerfDiagRates { home: number; } +interface AnalysisPerfDiag { + tracksPerMinute: number; + lastTotalMs: number | null; + lastFetchMs: number | null; + lastSeedMs: number | null; + lastBpmMs: number | null; +} + interface Props { open: boolean; onClose: () => void; perfFlags: PerfProbeFlags; perfCpu: PerfCpu | null; perfDiagRates: PerfDiagRates | null; + analysisPerf: AnalysisPerfDiag | null; hotCacheEnabled: boolean; setHotCacheEnabled: (v: boolean) => void; normalizationEngine: string; @@ -30,7 +39,7 @@ interface Props { } export default function SidebarPerfProbeModal({ - open, onClose, perfFlags, perfCpu, perfDiagRates, + open, onClose, perfFlags, perfCpu, perfDiagRates, analysisPerf, hotCacheEnabled, setHotCacheEnabled, normalizationEngine, setNormalizationEngine, loggingMode, setLoggingMode, @@ -56,6 +65,14 @@ export default function SidebarPerfProbeModal({ /> Show FPS overlay (requestAnimationFrame rate) +
Live CPU (approx)
{perfCpu == null ? ( @@ -71,6 +88,18 @@ export default function SidebarPerfProbeModal({
Home commits rate: {perfDiagRates.home.toFixed(1)}/s
)} + {analysisPerf && ( + <> +
analysis tpm (1m avg): {analysisPerf.tracksPerMinute.toFixed(1)}
+ {analysisPerf.lastTotalMs != null && ( +
+ last track: {(analysisPerf.lastTotalMs / 1000).toFixed(1)}s + {' '} + (fetch {(analysisPerf.lastFetchMs ?? 0) / 1000}s · seed {(analysisPerf.lastSeedMs ?? 0) / 1000}s · bpm {(analysisPerf.lastBpmMs ?? 0) / 1000}s) +
+ )} + + )} ) : (
Unavailable on this platform/build.
diff --git a/src/components/statistics/PlayerStatisticsPanel.tsx b/src/components/statistics/PlayerStatisticsPanel.tsx index 3398b5c5..2f6a548d 100644 --- a/src/components/statistics/PlayerStatisticsPanel.tsx +++ b/src/components/statistics/PlayerStatisticsPanel.tsx @@ -12,7 +12,6 @@ import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled'; import PlayerStatsHeatmap from './PlayerStatsHeatmap'; import PlayerStatsIndexRequiredNotice from './PlayerStatsIndexRequiredNotice'; -import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice'; import PlayerStatsRecentDays from './PlayerStatsRecentDays'; import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration'; @@ -99,7 +98,6 @@ export default function PlayerStatisticsPanel() { return (
-
diff --git a/src/components/statistics/PlayerStatsPartialIndexNotice.tsx b/src/components/statistics/PlayerStatsPartialIndexNotice.tsx deleted file mode 100644 index ebfca3ee..00000000 --- a/src/components/statistics/PlayerStatsPartialIndexNotice.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Info } from 'lucide-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { useAuthStore } from '../../store/authStore'; -import { useLibraryIndexStore } from '../../store/libraryIndexStore'; - -export default function PlayerStatsPartialIndexNotice() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const servers = useAuthStore(s => s.servers); - const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); - const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer); - - const excludedCount = useMemo( - () => servers.filter(s => syncExcludedByServer[s.id] === true).length, - [servers, syncExcludedByServer], - ); - - if (!masterEnabled || excludedCount === 0 || servers.length <= 1) { - return null; - } - - return ( -
- - - {t('statistics.playerPartialIndexNotice')} - {' '} - - -
- ); -} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 7b784575..56a485d5 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -128,6 +128,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Playback speed: global 0.5–2.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)', 'Local library index: full resync orphan sweep (IS-7) — remove server-deleted tracks after successful re-sync (PR #861)', 'Track enrichment: oximedia BPM/mood analysis, mood-group Advanced Search, queue display, unified playback analysis dispatch (PR #863)', + 'Server index-key rebuild follow-up: startup-safe migration orchestration, per-server analysis strategy controls, playback/cache scope hardening, and backup/restore for library databases with blocking progress UX (PR #864)', ], }, { @@ -324,6 +325,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Servers: inline edit for existing profiles (PR #780)', 'Interface Scale: scales the entire window — sidebar, queue, player bar, modals and the fullscreen player follow the main content (PR #781)', 'Local library index (preview): SQLite per-server track store, background initial and delta sync, live and Advanced Search against the local index, integrity verify and auto-reconcile on count drop (PR #846)', + 'Server index-key rebuild: safe dual-DB migration flow, per-server analysis strategy controls, and playback/index scope hardening (PR #864)', ], }, ] as const; diff --git a/src/hooks/useAnalysisPerfListener.ts b/src/hooks/useAnalysisPerfListener.ts new file mode 100644 index 00000000..8a9e147a --- /dev/null +++ b/src/hooks/useAnalysisPerfListener.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { recordAnalysisTrackPerf } from '../utils/perf/analysisPerfStore'; + +type AnalysisTrackPerfPayload = { + trackId: string; + fetchMs: number; + seedMs: number; + bpmMs: number; + totalMs: number; +}; + +/** Wire Rust `analysis:track-perf` events into the perf probe store. */ +export function useAnalysisPerfListener(active: boolean): void { + useEffect(() => { + if (!active) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void listen('analysis:track-perf', ({ payload }) => { + if (cancelled || !payload?.trackId) return; + recordAnalysisTrackPerf({ + trackId: payload.trackId, + fetchMs: payload.fetchMs ?? 0, + seedMs: payload.seedMs ?? 0, + bpmMs: payload.bpmMs ?? 0, + totalMs: payload.totalMs ?? 0, + }); + }).then(fn => { + if (cancelled) { + fn(); + return; + } + unlisten = fn; + }).catch(() => {}); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [active]); +} diff --git a/src/hooks/useLibraryAnalysisBackfill.ts b/src/hooks/useLibraryAnalysisBackfill.ts new file mode 100644 index 00000000..a56a256c --- /dev/null +++ b/src/hooks/useLibraryAnalysisBackfill.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; +import { buildStreamUrlForServer } from '../api/subsonicStreamUrl'; +import { + analysisEnqueueSeedFromUrl, + analysisGetPipelineQueueStats, + analysisSetPipelineParallelism, + libraryAnalysisBackfillBatch, +} from '../api/analysis'; +import { useAuthStore } from '../store/authStore'; +import { useAnalysisStrategyStore } from '../store/analysisStrategyStore'; +import { DEFAULT_ADVANCED_PARALLELISM } from '../utils/library/analysisStrategy'; +import { + libraryBackfillNeedsTopUp, + libraryBackfillTopUpLimit, +} from '../utils/library/libraryAnalysisBackfillPolicy'; +import { libraryIsReady } from '../utils/library/libraryReady'; + +const TOP_UP_POLL_MS = 500; +const STEADY_POLL_MS = 2000; +const READY_POLL_MS = 5000; +const EXHAUSTED_PAUSE_MS = 60_000; + +const EMPTY_PIPELINE_STATS = { + pipelineWorkers: 1, + httpQueued: 0, + httpQueuedHigh: 0, + httpQueuedMiddle: 0, + httpQueuedLow: 0, + httpDownloadActive: 0, + httpDownloadActiveHigh: 0, + httpDownloadActiveMiddle: 0, + httpDownloadActiveLow: 0, + cpuQueued: 0, + cpuQueuedHigh: 0, + cpuQueuedMiddle: 0, + cpuQueuedLow: 0, + cpuDecodeActive: 0, + cpuDecodeActiveHigh: 0, + cpuDecodeActiveMiddle: 0, + cpuDecodeActiveLow: 0, +}; + +/** + * Advanced analytics strategy: keep the HTTP backfill backlog above worker count + * (watermark target ≈ workers × 3) so parallel downloads stay busy. Library + * tracks enqueue at low priority; playback tiers stay ahead in Rust. + */ +export function useLibraryAnalysisBackfill(enabled = true): void { + const activeServerId = useAuthStore(s => s.activeServerId); + const strategy = useAnalysisStrategyStore(s => s.getStrategyForServer(activeServerId)); + const advancedParallelism = useAnalysisStrategyStore( + s => s.getAdvancedParallelismForServer(activeServerId), + ); + const cursorRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + const workers = + strategy === 'advanced' ? advancedParallelism : DEFAULT_ADVANCED_PARALLELISM; + void analysisSetPipelineParallelism(workers).catch(() => {}); + }, [strategy, advancedParallelism, enabled]); + + useEffect(() => { + if (!enabled || strategy !== 'advanced' || !activeServerId) return; + + let cancelled = false; + const serverId = activeServerId; + + void (async () => { + while (!cancelled) { + if (!(await libraryIsReady(serverId))) { + await new Promise(r => setTimeout(r, READY_POLL_MS)); + continue; + } + + const workers = advancedParallelism; + const stats = await analysisGetPipelineQueueStats().catch( + () => EMPTY_PIPELINE_STATS, + ); + + if (!libraryBackfillNeedsTopUp(stats, workers)) { + await new Promise(r => setTimeout(r, STEADY_POLL_MS)); + continue; + } + + const fetchLimit = libraryBackfillTopUpLimit(stats, workers); + if (fetchLimit <= 0) { + await new Promise(r => setTimeout(r, TOP_UP_POLL_MS)); + continue; + } + + const batch = await libraryAnalysisBackfillBatch( + serverId, + cursorRef.current, + fetchLimit, + ).catch(() => null); + + if (!batch || cancelled) { + await new Promise(r => setTimeout(r, TOP_UP_POLL_MS)); + continue; + } + + cursorRef.current = batch.nextCursor; + + await Promise.all( + batch.trackIds.map(trackId => + analysisEnqueueSeedFromUrl( + trackId, + buildStreamUrlForServer(serverId, trackId), + serverId, + 'low', + ).catch(() => { + /* Rust skips completed tracks */ + }), + ), + ); + + if (cancelled) return; + + if (batch.exhausted) { + cursorRef.current = null; + await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS)); + } else if (batch.trackIds.length === 0) { + await new Promise(r => setTimeout(r, TOP_UP_POLL_MS)); + } + // else: loop immediately if still below watermark + } + })(); + + return () => { + cancelled = true; + cursorRef.current = null; + }; + }, [strategy, activeServerId, advancedParallelism, enabled]); +} diff --git a/src/hooks/useLibraryIndexSync.ts b/src/hooks/useLibraryIndexSync.ts new file mode 100644 index 00000000..40be9dea --- /dev/null +++ b/src/hooks/useLibraryIndexSync.ts @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { showToast } from '../utils/ui/toast'; +import { resolveIndexKey, serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; +import { + libraryGetStatus, + librarySyncCancel, + 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'; + +export type LibraryServerConnection = 'online' | 'offline' | 'unknown'; + +const STATUS_POLL_MS = 3000; +const SYNC_POLL_MS = 2500; +const OFFLINE_RETRY_MS = 60_000; + +export function useLibraryIndexSync() { + const { t } = useTranslation(); + const servers = useAuthStore(s => s.servers); + const activeServerId = useAuthStore(s => s.activeServerId); + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + + const serverKeyById = useMemo( + () => Object.fromEntries(servers.map(s => [s.id, serverIndexKeyForProfile(s)])), + [servers], + ); + const indexedKeys = useMemo( + () => Array.from(new Set(Object.values(serverKeyById))), + [serverKeyById], + ); + const indexedServers = useMemo(() => { + const primary = new Map(); + for (const server of servers) { + const key = serverKeyById[server.id]; + if (!primary.has(key)) primary.set(key, { key, server }); + } + if (activeServerId) { + const active = servers.find(s => s.id === activeServerId); + if (active) { + const key = serverKeyById[active.id]; + if (primary.has(key)) primary.set(key, { key, server: active }); + } + } + return Array.from(primary.values()); + }, [servers, serverKeyById, activeServerId]); + + 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 ({ key, server }) => { + try { + const fresh = await libraryGetStatus(key); + syncPhaseRef.current[key] = fresh.syncPhase; + if (fresh.syncPhase === 'initial_sync') { + const next = Math.max(ingestCountRef.current[key] ?? 0, syncIngestDisplayCount(fresh)); + ingestCountRef.current[key] = next; + setProgressByServer(p => ({ + ...p, + [key]: t('settings.libraryIndexProgressIngest', { count: next }), + })); + } else if (fresh.syncPhase === 'ready' || fresh.syncPhase === 'idle') { + ingestCountRef.current[key] = 0; + } + return [key, fresh] as const; + } catch { + return [key, 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.key] === 'offline'); + if (offline.length === 0) return; + const results: Record = {}; + for (const srv of offline) { + results[srv.key] = await bootstrapIndexedServer(srv.server); + } + applyConnectionResults(results); + void refreshAllStatuses(); + }, [masterEnabled, indexedServers, connectionByServer, applyConnectionResults, refreshAllStatuses]); + + useEffect(() => { + if (!masterEnabled || indexedKeys.length === 0) return; + void runBootstrap(); + }, [masterEnabled, indexedKeys.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!masterEnabled) return; + const poll = () => { + void refreshAllStatuses(); + const anyInitial = indexedKeys.some( + key => syncPhaseRef.current[key] === '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 => { + const key = resolveIndexKey(p.serverId); + if (!indexedKeys.includes(key)) return; + setBusyServerId(key); + if (p.kind === 'ingest_page') { + const next = Math.max(ingestCountRef.current[key] ?? 0, p.ingestedTotal ?? 0); + ingestCountRef.current[key] = next; + setProgressByServer(prev => ({ + ...prev, + [key]: t('settings.libraryIndexProgressIngest', { count: next }), + })); + } else if (p.kind === 'tombstoned') { + setProgressByServer(prev => ({ + ...prev, + [key]: t('settings.libraryIndexProgressVerify', { + checked: p.tombstonesChecked ?? 0, + deleted: p.tombstonesDeleted ?? 0, + }), + })); + } else if (p.kind === 'phase_changed' && p.phase) { + setProgressByServer(prev => ({ ...prev, [key]: p.phase ?? null })); + } + }), + subscribeLibrarySyncIdle(p => { + const key = resolveIndexKey(p.serverId); + if (!indexedKeys.includes(key)) return; + setBusyServerId(cur => (cur === key ? null : cur)); + ingestCountRef.current[key] = 0; + setProgressByServer(prev => ({ ...prev, [key]: 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, indexedKeys, refreshAllStatuses, t]); + + const runServerAction = useCallback(async ( + serverId: string, + action: 'full' | 'delta' | 'verify', + ) => { + const key = resolveIndexKey(serverId); + setBusyServerId(key); + try { + const kind = + action === 'verify' + ? 'verify' + : action === 'full' + ? 'full' + : statusByServer[key]?.lastFullSyncAt + ? 'delta' + : 'full'; + ingestCountRef.current[key] = 0; + await enqueueLibrarySync({ serverId: key, kind }); + } catch (e) { + setBusyServerId(null); + showToast(t('settings.libraryIndexSyncError', { error: String(e) }), 5000, 'error'); + } + }, [statusByServer, t]); + + const handleCancel = useCallback(async () => { + try { + await librarySyncCancel(); + } catch { + /* best-effort */ + } + }, []); + + const globalBusy = bootstrapping || busyServerId != null; + + return { + statusByServer, + connectionByServer, + progressByServer, + busyServerId, + bootstrapping, + globalBusy, + runServerAction, + handleCancel, + }; +} diff --git a/src/hooks/useMigrationOrchestrator.test.ts b/src/hooks/useMigrationOrchestrator.test.ts new file mode 100644 index 00000000..fc766065 --- /dev/null +++ b/src/hooks/useMigrationOrchestrator.test.ts @@ -0,0 +1,148 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../store/authStore'; +import { useMigrationStore } from '../store/migrationStore'; + +const migrationInspectMock = vi.fn(); +const migrationRunMock = vi.fn(); +const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(async () => () => {}), +})); + +vi.mock('../api/migration', () => ({ + migrationInspect: (mappings: unknown) => migrationInspectMock(mappings), + migrationRun: (mappings: unknown) => migrationRunMock(mappings), +})); + +vi.mock('../utils/server/rewriteFrontendStoreKeys', () => ({ + rewriteFrontendStoreKeys: (servers: unknown) => rewriteFrontendStoreKeysMock(servers), +})); + +import { useMigrationOrchestrator } from './useMigrationOrchestrator'; + +const DONE_FLAG = 'psysonic-server-key-migration-v1'; +const REAL_MIGRATION_TEST_OVERRIDE = '__PSYSONIC_REAL_MIGRATION_TEST__'; + +describe('useMigrationOrchestrator', () => { + beforeEach(() => { + migrationInspectMock.mockReset(); + migrationRunMock.mockReset(); + rewriteFrontendStoreKeysMock.mockClear(); + localStorage.clear(); + useAuthStore.setState({ + servers: [ + { id: 'legacy-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }, + ], + activeServerId: 'legacy-a', + isLoggedIn: true, + }); + useMigrationStore.setState({ + phase: 'inspecting', + needsMigration: false, + inspect: null, + progress: null, + lastError: null, + }); + (globalThis as Record)[REAL_MIGRATION_TEST_OVERRIDE] = true; + }); + + afterEach(() => { + delete (globalThis as Record)[REAL_MIGRATION_TEST_OVERRIDE]; + }); + + it('orchestrator_completes_when_no_needsMigration_but_hasSkippedUnknownServerRows', async () => { + migrationInspectMock.mockResolvedValue({ + needsMigration: false, + hasSkippedUnknownServerRows: true, + canRun: true, + warnings: ['rows for removed servers were skipped'], + unmappedEmptyBucket: false, + library: { totalLegacyRows: 0, skippedUnknownServerRows: 12, tables: {} }, + analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 4, tables: {} }, + mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }], + }); + + renderHook(() => useMigrationOrchestrator()); + + await waitFor(() => { + expect(useMigrationStore.getState().phase).toBe('completed'); + }); + expect(useMigrationStore.getState().lastError).toBeNull(); + }); + + it('done flag is set when no_needsMigration_and_hasSkippedUnknownServerRows', async () => { + migrationInspectMock.mockResolvedValue({ + needsMigration: false, + hasSkippedUnknownServerRows: true, + canRun: true, + warnings: ['rows for removed servers were skipped'], + unmappedEmptyBucket: false, + library: { totalLegacyRows: 0, skippedUnknownServerRows: 7, tables: {} }, + analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} }, + mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }], + }); + + renderHook(() => useMigrationOrchestrator()); + + await waitFor(() => { + expect(useMigrationStore.getState().phase).toBe('completed'); + }); + expect(localStorage.getItem(DONE_FLAG)).toBe('1'); + }); + + it('keeps completed phase on startup when done flag exists and no migration is needed', async () => { + localStorage.setItem(DONE_FLAG, '1'); + useMigrationStore.setState({ phase: 'completed' }); + migrationInspectMock.mockResolvedValue({ + needsMigration: false, + hasSkippedUnknownServerRows: false, + canRun: true, + warnings: [], + unmappedEmptyBucket: false, + library: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} }, + analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} }, + mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }], + }); + + renderHook(() => useMigrationOrchestrator()); + + await waitFor(() => { + expect(useMigrationStore.getState().phase).toBe('completed'); + }); + expect(migrationRunMock).not.toHaveBeenCalled(); + expect(rewriteFrontendStoreKeysMock).not.toHaveBeenCalled(); + }); + + it('keeps startup non-blocking while done-flag precheck is pending', async () => { + localStorage.setItem(DONE_FLAG, '1'); + let resolveInspect: ((value: any) => void) | undefined; + migrationInspectMock.mockImplementation( + () => new Promise(resolve => { resolveInspect = resolve; }), + ); + + renderHook(() => useMigrationOrchestrator()); + + await waitFor(() => { + expect(useMigrationStore.getState().phase).toBe('idle'); + }); + expect(migrationRunMock).not.toHaveBeenCalled(); + + if (!resolveInspect) throw new Error('inspect resolver not captured'); + resolveInspect({ + needsMigration: false, + hasSkippedUnknownServerRows: false, + canRun: true, + warnings: [], + unmappedEmptyBucket: false, + library: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} }, + analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} }, + mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }], + }); + + await waitFor(() => { + expect(useMigrationStore.getState().phase).toBe('completed'); + }); + }); +}); diff --git a/src/hooks/useMigrationOrchestrator.ts b/src/hooks/useMigrationOrchestrator.ts new file mode 100644 index 00000000..9d7dcfb8 --- /dev/null +++ b/src/hooks/useMigrationOrchestrator.ts @@ -0,0 +1,134 @@ +import { useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { migrationInspect, migrationRun, type ServerIndexMapping } from '../api/migration'; +import { useAuthStore } from '../store/authStore'; +import { useMigrationStore } from '../store/migrationStore'; +import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey'; +import { rewriteFrontendStoreKeys } from '../utils/server/rewriteFrontendStoreKeys'; + +const MIGRATION_DONE_FLAG = 'psysonic-server-key-migration-v1'; +let migrationInFlight: Promise | null = null; +const REAL_MIGRATION_TEST_OVERRIDE = '__PSYSONIC_REAL_MIGRATION_TEST__'; + +function logSkippedUnknownRowsOnce( + report: Awaited>, + alreadyLogged: boolean, +): boolean { + if (!alreadyLogged && report.hasSkippedUnknownServerRows) { + console.warn('[migration] rows for removed servers were skipped'); + return true; + } + return alreadyLogged; +} + +function buildMappings(): ServerIndexMapping[] { + return useAuthStore.getState().servers + .map(server => ({ + legacyId: server.id, + indexKey: serverIndexKeyFromUrl(server.url), + })) + .filter(mapping => mapping.legacyId.trim().length > 0 && mapping.indexKey.trim().length > 0); +} + +async function runOrchestrator(force = false): Promise { + if (migrationInFlight) { + await migrationInFlight; + return; + } + migrationInFlight = (async () => { + const state = useMigrationStore.getState(); + let skippedLogged = false; + if (import.meta.env.MODE === 'test' && !(globalThis as Record)[REAL_MIGRATION_TEST_OVERRIDE]) { + state.setNeedsMigration(false); + state.setPhase('completed'); + return; + } + const servers = useAuthStore.getState().servers; + if (servers.length === 0) { + state.setNeedsMigration(false); + state.setPhase('completed'); + return; + } + const mappings = buildMappings(); + const hasDoneFlag = localStorage.getItem(MIGRATION_DONE_FLAG) === '1'; + state.setError(null); + state.setProgress(null); + state.setPhase(force ? 'inspecting' : 'idle'); + let inspect = null as Awaited> | null; + if (!force && hasDoneFlag) { + inspect = await migrationInspect(mappings); + state.setInspect(inspect); + state.setNeedsMigration(inspect.needsMigration); + skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged); + if (!inspect.needsMigration) { + state.setPhase('completed'); + return; + } + } + if (!inspect) { + inspect = await migrationInspect(mappings); + } + state.setInspect(inspect); + state.setNeedsMigration(inspect.needsMigration); + skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged); + if (!inspect.needsMigration) { + await rewriteFrontendStoreKeys(servers); + localStorage.setItem(MIGRATION_DONE_FLAG, '1'); + state.setPhase('completed'); + return; + } + state.setPhase('inspecting'); + state.setPhase('running'); + await migrationRun(mappings); + await rewriteFrontendStoreKeys(servers); + state.setPhase('inspecting'); + const after = await migrationInspect(mappings); + state.setInspect(after); + state.setNeedsMigration(after.needsMigration); + skippedLogged = logSkippedUnknownRowsOnce(after, skippedLogged); + if (!after.needsMigration) { + localStorage.setItem(MIGRATION_DONE_FLAG, '1'); + state.setPhase('completed'); + return; + } + state.setError('Migration incomplete. Retry after adding missing server mapping.'); + state.setPhase('error'); + })() + .catch((error: unknown) => { + useMigrationStore.getState().setError(String(error)); + useMigrationStore.getState().setPhase('error'); + }) + .finally(() => { + migrationInFlight = null; + }); + await migrationInFlight; +} + +export function retryServerIndexMigration(): void { + void runOrchestrator(true); +} + +export function useMigrationOrchestrator(): void { + const servers = useAuthStore(s => s.servers); + + useEffect(() => { + let disposed = false; + const sub = listen('migration:progress', (event) => { + if (disposed) return; + useMigrationStore.getState().setProgress(event.payload as { + stage: string; + table: string; + done: number; + total: number; + }); + }); + return () => { + disposed = true; + void sub.then(unlisten => unlisten()); + }; + }, []); + + useEffect(() => { + void runOrchestrator(); + }, [servers]); +} diff --git a/src/hooks/usePlayerStatsRecordingEnabled.ts b/src/hooks/usePlayerStatsRecordingEnabled.ts index aad5856f..acad0053 100644 --- a/src/hooks/usePlayerStatsRecordingEnabled.ts +++ b/src/hooks/usePlayerStatsRecordingEnabled.ts @@ -5,6 +5,6 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore'; export function usePlayerStatsRecordingEnabled(): boolean { const servers = useAuthStore(s => s.servers); const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); - const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer); - return masterEnabled && servers.some(s => syncExcludedByServer[s.id] !== true); + const indexedServerIds = useLibraryIndexStore(s => s.indexedServerIds); + return masterEnabled && indexedServerIds(servers.map(s => s.id)).length > 0; } diff --git a/src/hooks/useShareSearch.ts b/src/hooks/useShareSearch.ts index 2f139e11..6525db8d 100644 --- a/src/hooks/useShareSearch.ts +++ b/src/hooks/useShareSearch.ts @@ -10,6 +10,7 @@ import type { ServerProfile } from '../store/authStoreTypes'; import { findServerIdForShareUrl } from '../utils/share/shareLink'; import { shareServerOriginLabel } from '../utils/share/shareServerOriginLabel'; import { parseShareSearchText } from '../utils/share/shareSearch'; +import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey'; import { useShareSearchPreview } from './useShareSearchPreview'; export function useShareSearch(query: string, onSuccess?: () => void) { @@ -26,7 +27,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) { if (!shareMatch || shareMatch.type === 'unsupported') return null; const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv); if (!serverId || serverId === activeServerId) return null; - return servers.find(s => s.id === serverId) ?? null; + return servers.find(s => s.id === serverId) + ?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId) + ?? null; }, [shareMatch, servers, activeServerId]); const preview = useShareSearchPreview(shareMatch); const [shareQueueBusy, setShareQueueBusy] = useState(false); diff --git a/src/hooks/useSidebarPerfProbe.ts b/src/hooks/useSidebarPerfProbe.ts index 8e2255c2..48a717fb 100644 --- a/src/hooks/useSidebarPerfProbe.ts +++ b/src/hooks/useSidebarPerfProbe.ts @@ -1,6 +1,11 @@ import { useEffect, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { setPerfProbeTelemetryActive } from '../utils/perf/perfTelemetry'; +import { + getAnalysisTracksPerMinute, + useAnalysisPerfLast, +} from '../utils/perf/analysisPerfStore'; +import { useAnalysisPerfListener } from './useAnalysisPerfListener'; interface PerfCpu { app: number; @@ -14,11 +19,20 @@ interface PerfDiagRates { home: number; } +interface AnalysisPerfDiag { + tracksPerMinute: number; + lastTotalMs: number | null; + lastFetchMs: number | null; + lastSeedMs: number | null; + lastBpmMs: number | null; +} + interface Result { perfProbeOpen: boolean; setPerfProbeOpen: (open: boolean) => void; perfCpu: PerfCpu | null; perfDiagRates: PerfDiagRates | null; + analysisPerf: AnalysisPerfDiag | null; } /** Wires up Ctrl+Shift+D to open the perf probe; polls CPU + diag-rate counters @@ -27,6 +41,10 @@ export function useSidebarPerfProbe(): Result { const [perfProbeOpen, setPerfProbeOpen] = useState(false); const [perfCpu, setPerfCpu] = useState(null); const [perfDiagRates, setPerfDiagRates] = useState(null); + const [analysisTpm, setAnalysisTpm] = useState(0); + const analysisLast = useAnalysisPerfLast(); + + useAnalysisPerfListener(perfProbeOpen); useEffect(() => { setPerfProbeTelemetryActive(perfProbeOpen); @@ -111,10 +129,19 @@ export function useSidebarPerfProbe(): Result { }; }, [perfProbeOpen]); + useEffect(() => { + if (!perfProbeOpen) return; + const refresh = () => setAnalysisTpm(getAnalysisTracksPerMinute()); + refresh(); + const id = window.setInterval(refresh, 2000); + return () => window.clearInterval(id); + }, [perfProbeOpen, analysisLast?.at]); + useEffect(() => { if (!perfProbeOpen) { setPerfCpu(null); setPerfDiagRates(null); + setAnalysisTpm(0); } }, [perfProbeOpen]); @@ -136,5 +163,19 @@ export function useSidebarPerfProbe(): Result { return () => window.removeEventListener('keydown', onKey); }, []); - return { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates }; + return { + perfProbeOpen, + setPerfProbeOpen, + perfCpu, + perfDiagRates, + analysisPerf: perfProbeOpen + ? { + tracksPerMinute: analysisTpm, + lastTotalMs: analysisLast?.totalMs ?? null, + lastFetchMs: analysisLast?.fetchMs ?? null, + lastSeedMs: analysisLast?.seedMs ?? null, + lastBpmMs: analysisLast?.bpmMs ?? null, + } + : null, + }; } diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index 2cc2bb8c..4d47383a 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -1,5 +1,5 @@ import { buildStreamUrlForServer } from './api/subsonicStreamUrl'; -import { getPlaybackServerId } from './utils/playback/playbackServer'; +import { getPlaybackCacheServerKey } from './utils/playback/playbackServer'; import type { Track } from './store/playerStoreTypes'; import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from './store/authStore'; @@ -74,7 +74,7 @@ async function runWorker() { try { while (pendingQueue.length > 0) { const auth = useAuthStore.getState(); - const playbackSid = getPlaybackServerId(); + const playbackSid = getPlaybackCacheServerKey(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) { hotCacheFrontendDebug({ event: 'prefetch-worker-stop', @@ -175,7 +175,7 @@ async function runWorker() { fresh.queue, fresh.queueIndex, maxAfter, - getPlaybackServerId(), + getPlaybackCacheServerKey(), authAfter.hotCacheDownloadDir || null, ); } catch (e: unknown) { @@ -190,7 +190,7 @@ async function runWorker() { function scheduleReplan() { const auth = useAuthStore.getState(); - const playbackSid = getPlaybackServerId(); + const playbackSid = getPlaybackCacheServerKey(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) { if (debounceTimer) { clearTimeout(debounceTimer); @@ -209,7 +209,7 @@ function scheduleReplan() { async function replanNow() { const auth = useAuthStore.getState(); - const playbackSid = getPlaybackServerId(); + const playbackSid = getPlaybackCacheServerKey(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) return; const serverId = playbackSid; @@ -290,7 +290,7 @@ export function initHotCachePrefetch(): () => void { if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) { const left = (prevQ as Track[])[prevIdx]; const a = useAuthStore.getState(); - const graceSid = getPlaybackServerId(); + const graceSid = getPlaybackCacheServerKey(); if (left && graceSid) { bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec); scheduleEvictAfterPreviousGrace(); diff --git a/src/hotCachePrefetch/analysisPrune.ts b/src/hotCachePrefetch/analysisPrune.ts index 476c88de..025c1ed7 100644 --- a/src/hotCachePrefetch/analysisPrune.ts +++ b/src/hotCachePrefetch/analysisPrune.ts @@ -1,5 +1,10 @@ import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; +import { collectPlaybackMiddlePriorityTrackIds } from '../store/loudnessBackfillWindow'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { analysisSetPlaybackPriorityHints } from '../api/analysis'; +import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey'; import { hotCacheFrontendDebug } from './helpers'; let analysisPruneTimer: ReturnType | null = null; @@ -14,7 +19,11 @@ type AnalysisPrunePendingResult = { }; export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void { - const { queue, currentTrack } = usePlayerStore.getState(); + const { queue, currentTrack, queueIndex } = usePlayerStore.getState(); + const { preloadMode } = useAuthStore.getState(); + const rawServerId = getPlaybackServerId() ?? ''; + const server = useAuthStore.getState().servers.find(s => s.id === rawServerId); + const serverId = server ? serverIndexKeyFromUrl(server.url) : rawServerId; const keepTrackIds: string[] = []; const seen = new Set(); const pushId = (id: string | undefined | null) => { @@ -29,7 +38,13 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void { pushId(track.id); if (keepTrackIds.length >= 1000) break; } - const sig = JSON.stringify(keepTrackIds); + const middleTrackIds = collectPlaybackMiddlePriorityTrackIds( + queue, + queueIndex, + currentTrack, + preloadMode, + ); + const sig = JSON.stringify({ keepTrackIds, middleTrackIds, serverId }); if (sig === lastAnalysisPruneSig) return; lastAnalysisPruneSig = sig; if (analysisPruneTimer) { @@ -38,7 +53,12 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void { } analysisPruneTimer = setTimeout(() => { analysisPruneTimer = null; - void invoke('analysis_prune_pending_to_track_ids', { trackIds: keepTrackIds }) + const middleTrackRefs = middleTrackIds.map(trackId => ({ serverId, trackId })); + void analysisSetPlaybackPriorityHints(middleTrackRefs).catch(() => {}); + void invoke('analysis_prune_pending_to_track_ids', { + trackIds: keepTrackIds, + serverId, + }) .then(result => { if (!result) return; hotCacheFrontendDebug({ diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 2d3f29c8..ce40a264 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -283,6 +283,33 @@ export const settings = { libraryIndexAutoReconcileDesc: 'Automatically check for removed tracks when the server reports fewer than expected.', libraryIndexSyncError: 'Library sync failed: {{error}}', libraryIndexBindError: 'Could not enable index: {{error}}', + analyticsStrategyTitle: 'Analytics strategies', + analyticsStrategyDesc: 'Choose the analysis style for the library.', + analyticsStrategyLabel: 'Strategy', + analyticsStrategyLazy: 'Lazy', + analyticsStrategyLazyDesc: 'Analyze only when tracks play or enter the hot/offline cache — minimal CPU and network use.', + analyticsStrategyAdvanced: 'Aggressive', + analyticsStrategyAdvancedDesc: 'More aggressive background scan. Once finished, you get BPM for every track plus instant loudness + waveform loads.', + analyticsStrategyPriorityTitle: 'Analysis priority (always enforced)', + analyticsStrategyPriorityHigh: '1 — Currently playing: download + analyze first (playback bytes, loudness backfill, or HTTP when needed).', + analyticsStrategyPriorityMiddle: '2 — Up next: the next ~5 queue tracks, preload next, and hot-cache neighbours — analyze from cached/downloaded bytes when possible; HTTP backfill only on miss.', + analyticsStrategyPriorityLow: '3 — Library batch (Aggressive only): automatic background queue at the tail; refilled by watermark (~3× workers) without waiting for an empty queue.', + analyticsStrategyServerLabel: 'Server', + analyticsStrategyProgressLabel: 'Analysis progress:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Actions', + analyticsStrategyParallelismLabel: 'Parallel workers', + analyticsStrategyParallelismValue: '{{n}} threads', + analyticsStrategyParallelismDesc: 'How many tracks to download and analyze at once (HTTP + CPU). Higher values speed up library catch-up but use more network and CPU.', + analyticsStrategyAdvancedWarning: 'Aggressive uses more CPU and network bandwidth while the library batch runs in the background.', + analyticsStrategyServerRemoved: 'Removed server', + analyticsStrategyClearAction: 'Clear analysis', + analyticsStrategyClearTitle: 'Clear analysis?', + analyticsStrategyClearDesc: 'Clearing analysis for {{server}} removes cached waveforms and loudness. Re-adding this server later may require long re-indexing.', + analyticsStrategyClearCancel: 'Cancel', + analyticsStrategyClearConfirm: 'Clear analysis', + analyticsStrategyClearSuccess: 'Analysis cleared for the removed server.', + analyticsStrategyClearError: 'Failed to clear analysis for the removed server.', randomMixTitle: 'Random Mix Blacklist', luckyMixMenuTitle: 'Show Lucky Mix in menu', luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.', @@ -383,9 +410,32 @@ export const settings = { backupImport: 'Import settings', backupImportDesc: 'Restores settings from a .psybkp file. The app will reload after import.', backupImportConfirm: 'This will overwrite all current settings. Continue?', + backupImportAny: 'Import backup', + backupImportAnyConfirm: 'Import selected backup file and apply its contents (settings, library databases, or full). Continue?', backupSuccess: 'Backup saved', backupImportSuccess: 'Settings restored — reloading…', backupImportError: 'Invalid or corrupted backup file.', + backupModeFull: 'Full', + backupModeLibrary: 'Library databases', + backupModeConfig: 'Config', + backupFullDesc: 'Archives settings and library databases into one backup file.', + backupFullExport: 'Export full backup', + backupFullImport: 'Import full backup', + backupFullImportConfirm: 'This will overwrite settings, replace current library databases, and move them to .bak. Continue?', + backupFullExportSuccess: 'Full backup exported.', + backupFullImportSuccess: 'Full backup imported — reloading…', + backupFullImportError: 'Could not import full backup.', + backupLibraryExport: 'Export databases', + backupLibraryExportDesc: 'Creates a compressed archive with clean SQLite snapshots of library databases.', + backupLibraryImport: 'Import databases', + backupLibraryImportDesc: 'Restores library databases from an archive file. Current databases are moved to .bak before switch.', + backupLibraryImportConfirm: 'This will replace current library databases and move them to .bak. Continue?', + backupLibraryExportSuccess: 'Databases backup exported.', + backupLibraryImportSuccess: 'Databases backup imported.', + backupLibraryImportError: 'Could not import databases backup.', + backupOverlayExportTitle: 'Creating backup…', + backupOverlayImportTitle: 'Restoring backup…', + backupOverlayHint: 'You can keep the window open while we process files. Input is temporarily locked.', shortcutsReset: 'Reset to defaults', shortcutListening: 'Press a key…', shortcutUnbound: '—', diff --git a/src/locales/en/statistics.ts b/src/locales/en/statistics.ts index d994c2e1..ef110d7c 100644 --- a/src/locales/en/statistics.ts +++ b/src/locales/en/statistics.ts @@ -64,7 +64,7 @@ export const statistics = { exportSaveFailed: 'Could not save the image.', tabServer: 'Server stats', tabPlayer: 'Player stats', - playerEmpty: 'Start listening — your local play history will appear here once the library index is enabled.', + playerEmpty: 'Start listening — your local play history will appear here.', playerSummaryTime: 'Listening time', playerListeningDayShort: '{{count}}d', playerListeningHourShort: '{{count}}h', @@ -73,9 +73,8 @@ export const statistics = { playerSummaryTracks: 'Track plays', playerSummaryUniqueTracks: 'Unique tracks', playerSummaryDays: 'Days', - playerPartialIndexNotice: 'Some servers are excluded from the local library index. Listening on those servers is not recorded in player statistics.', - playerIndexRequired: 'Player statistics are not available until you enable the local library index.', - playerPartialIndexSettings: 'Library settings', + playerIndexRequired: 'Player statistics require at least one configured server.', + playerPartialIndexSettings: 'Server settings', playerSummaryCompletion: 'Full / partial', playerYearPrev: 'Previous year', playerYearNext: 'Next year', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 2c537afd..8a4c74cc 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -288,6 +288,33 @@ export const settings = { libraryIndexAutoReconcileDesc: 'Автоматически искать удалённые треки, когда на сервере их меньше ожидаемого.', libraryIndexSyncError: 'Ошибка синхронизации: {{error}}', libraryIndexBindError: 'Не удалось включить индекс: {{error}}', + analyticsStrategyTitle: 'Стратегии аналитики', + analyticsStrategyDesc: 'Выберите стиль анализа для библиотеки.', + analyticsStrategyLabel: 'Стратегия', + analyticsStrategyLazy: 'Ленивая', + analyticsStrategyLazyDesc: 'Анализ только при проигрывании или попадании в горячий/офлайн-кэш — минимальная нагрузка на CPU и сеть.', + analyticsStrategyAdvanced: 'Агрессивный', + analyticsStrategyAdvancedDesc: 'Более агрессивный фоновый прогон. После завершения — BPM для всех треков и мгновенная загрузка loudness + waveform.', + analyticsStrategyPriorityTitle: 'Приоритет анализа (всегда)', + analyticsStrategyPriorityHigh: '1 — Сейчас играет: скачивание и анализ в первую очередь (байты воспроизведения, loudness backfill или HTTP при необходимости).', + analyticsStrategyPriorityMiddle: '2 — Следующие: ~5 треков в очереди, preload next и соседи hot cache — анализ из уже скачанных байтов, HTTP backfill только при промахе.', + analyticsStrategyPriorityLow: '3 — Библиотечный batch (только Агрессивный): автоматическая фоновая очередь в хвосте; пополняется по watermark (~3× потоков), не дожидаясь полного опустошения.', + analyticsStrategyServerLabel: 'Сервер', + analyticsStrategyProgressLabel: 'Прогресс анализа:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Действия', + analyticsStrategyParallelismLabel: 'Параллельные потоки', + analyticsStrategyParallelismValue: '{{n}} потоков', + analyticsStrategyParallelismDesc: 'Сколько треков одновременно скачивать и анализировать (HTTP + CPU). Больше значение — быстрее догоняет библиотеку, но выше нагрузка на сеть и CPU.', + analyticsStrategyAdvancedWarning: 'Агрессивный режим даёт большую нагрузку на CPU и сеть, пока идёт фоновый прогон библиотеки.', + analyticsStrategyServerRemoved: 'Удалённый сервер', + analyticsStrategyClearAction: 'Очистить анализ', + analyticsStrategyClearTitle: 'Очистить анализ?', + analyticsStrategyClearDesc: 'Очистка анализа для {{server}} удалит кэш волн и loudness. При повторном добавлении сервера индексация может занять много времени.', + analyticsStrategyClearCancel: 'Отмена', + analyticsStrategyClearConfirm: 'Очистить анализ', + analyticsStrategyClearSuccess: 'Анализ удалён для удалённого сервера.', + analyticsStrategyClearError: 'Не удалось очистить анализ для удалённого сервера.', randomMixTitle: 'Чёрный список случайного микса', luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню', luckyMixMenuDesc: @@ -395,9 +422,36 @@ export const settings = { backupImport: 'Импорт настроек', backupImportDesc: 'Восстановить из .psybkp. Приложение перезапустится.', backupImportConfirm: 'Текущие настройки будут заменены. Продолжить?', + backupImportAny: 'Импорт резервной копии', + backupImportAnyConfirm: + 'Импортировать выбранный файл резервной копии и применить его содержимое (настройки, базы библиотеки или полный архив). Продолжить?', backupSuccess: 'Копия сохранена', backupImportSuccess: 'Настройки восстановлены — перезапуск…', backupImportError: 'Файл повреждён или не подходит.', + backupModeFull: 'Полный', + backupModeLibrary: 'Базы библиотеки', + backupModeConfig: 'Настройки', + backupFullDesc: 'Архивирует настройки и базы библиотеки в один файл резервной копии.', + backupFullExport: 'Экспорт полного архива', + backupFullImport: 'Импорт полного архива', + backupFullImportConfirm: + 'Настройки будут перезаписаны, текущие базы библиотеки будут заменены и сохранены в .bak. Продолжить?', + backupFullExportSuccess: 'Полный архив экспортирован.', + backupFullImportSuccess: 'Полный архив импортирован — перезапуск…', + backupFullImportError: 'Не удалось импортировать полный архив.', + backupLibraryExport: 'Экспорт баз', + backupLibraryExportDesc: 'Создаёт сжатый архив с чистыми SQLite-снимками баз библиотеки.', + backupLibraryImport: 'Импорт баз', + backupLibraryImportDesc: + 'Восстанавливает базы библиотеки из архива. Текущие базы перед заменой переносятся в .bak.', + backupLibraryImportConfirm: + 'Текущие базы библиотеки будут заменены и сохранены в .bak. Продолжить?', + backupLibraryExportSuccess: 'Копия баз экспортирована.', + backupLibraryImportSuccess: 'Копия баз импортирована.', + backupLibraryImportError: 'Не удалось импортировать копию баз.', + backupOverlayExportTitle: 'Создаём резервную копию…', + backupOverlayImportTitle: 'Восстанавливаем резервную копию…', + backupOverlayHint: 'Окно можно не закрывать, идёт обработка файлов. Ввод временно заблокирован.', shortcutsReset: 'Сбросить', shortcutListening: 'Нажмите клавишу…', shortcutUnbound: '—', diff --git a/src/locales/ru/statistics.ts b/src/locales/ru/statistics.ts index c5d8e1ed..7671a574 100644 --- a/src/locales/ru/statistics.ts +++ b/src/locales/ru/statistics.ts @@ -57,7 +57,7 @@ export const statistics = { noRatedArtists: 'Нет оценённых исполнителей.', tabServer: 'Статистика сервера', tabPlayer: 'Статистика плеера', - playerEmpty: 'Начните слушать — локальная история появится здесь при включённом индексе библиотеки.', + playerEmpty: 'Начните слушать — локальная история появится здесь.', playerSummaryTime: 'Время прослушивания', playerListeningDayShort: '{{count}}д', playerListeningHourShort: '{{count}}ч', @@ -65,10 +65,10 @@ export const statistics = { playerSummarySessions: 'Сессии', playerSummaryTracks: 'Треки', playerSummaryUniqueTracks: 'Уникальные треки', - playerSummaryDays: 'Days', + playerSummaryDays: 'Дни', playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.', - playerIndexRequired: 'Статистика плеера недоступна, пока не включён локальный индекс библиотеки.', - playerPartialIndexSettings: 'Настройки библиотеки', + playerIndexRequired: 'Статистика плеера требует хотя бы одного настроенного сервера.', + playerPartialIndexSettings: 'Настройки серверов', playerSummaryCompletion: 'Полные / частичные', playerYearPrev: 'Предыдущий год', playerYearNext: 'Следующий год', diff --git a/src/store/analysisStrategyStore.test.ts b/src/store/analysisStrategyStore.test.ts new file mode 100644 index 00000000..6bf9f277 --- /dev/null +++ b/src/store/analysisStrategyStore.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAnalysisStrategyStore } from './analysisStrategyStore'; +import { + DEFAULT_ADVANCED_PARALLELISM, + DEFAULT_ANALYTICS_STRATEGY, +} from '../utils/library/analysisStrategy'; + +describe('analysisStrategyStore', () => { + beforeEach(() => { + useAnalysisStrategyStore.setState({ + strategy: DEFAULT_ANALYTICS_STRATEGY, + advancedParallelism: DEFAULT_ADVANCED_PARALLELISM, + strategyByServer: {}, + advancedParallelismByServer: {}, + }); + }); + + it('defaults to lazy', () => { + expect(useAnalysisStrategyStore.getState().strategy).toBe('lazy'); + }); + + it('defaults advanced parallelism to 1', () => { + expect(useAnalysisStrategyStore.getState().advancedParallelism).toBe(1); + }); + + it('persists strategy changes in memory', () => { + useAnalysisStrategyStore.getState().setStrategy('advanced'); + expect(useAnalysisStrategyStore.getState().strategy).toBe('advanced'); + }); + + it('clamps advanced parallelism to 1–20', () => { + useAnalysisStrategyStore.getState().setAdvancedParallelism(99); + expect(useAnalysisStrategyStore.getState().advancedParallelism).toBe(20); + useAnalysisStrategyStore.getState().setAdvancedParallelism(0); + expect(useAnalysisStrategyStore.getState().advancedParallelism).toBe(1); + }); + + it('tracks per-server strategy overrides', () => { + const store = useAnalysisStrategyStore.getState(); + store.setServerStrategy('s1', 'advanced'); + expect(store.getStrategyForServer('s1')).toBe('advanced'); + expect(store.getStrategyForServer('s2')).toBe(DEFAULT_ANALYTICS_STRATEGY); + }); + + it('tracks per-server parallelism overrides', () => { + const store = useAnalysisStrategyStore.getState(); + store.setServerAdvancedParallelism('s1', 8); + expect(store.getAdvancedParallelismForServer('s1')).toBe(8); + expect(store.getAdvancedParallelismForServer('s2')).toBe(DEFAULT_ADVANCED_PARALLELISM); + }); + + it('clears per-server overrides', () => { + const store = useAnalysisStrategyStore.getState(); + store.setServerStrategy('s1', 'advanced'); + store.setServerAdvancedParallelism('s1', 6); + store.clearServerOverrides('s1'); + expect(store.getStrategyForServer('s1')).toBe(DEFAULT_ANALYTICS_STRATEGY); + expect(store.getAdvancedParallelismForServer('s1')).toBe(DEFAULT_ADVANCED_PARALLELISM); + }); +}); diff --git a/src/store/analysisStrategyStore.ts b/src/store/analysisStrategyStore.ts new file mode 100644 index 00000000..e07c4772 --- /dev/null +++ b/src/store/analysisStrategyStore.ts @@ -0,0 +1,147 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { + clampAdvancedParallelism, + DEFAULT_ADVANCED_PARALLELISM, + DEFAULT_ANALYTICS_STRATEGY, + type AnalyticsStrategy, +} from '../utils/library/analysisStrategy'; +import { useAuthStore } from './authStore'; +import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey'; +import type { ServerProfile } from './authStoreTypes'; + +const resolveStrategyKey = (serverId: string): string => { + const server = useAuthStore.getState().servers.find(s => s.id === serverId); + if (!server) return serverId; + return serverIndexKeyFromUrl(server.url) || serverId; +}; + +interface AnalysisStrategyState { + strategy: AnalyticsStrategy; + advancedParallelism: number; + strategyByServer: Record; + advancedParallelismByServer: Record; + setStrategy: (strategy: AnalyticsStrategy) => void; + setAdvancedParallelism: (workers: number) => void; + setServerStrategy: (serverId: string, strategy: AnalyticsStrategy) => void; + setServerAdvancedParallelism: (serverId: string, workers: number) => void; + clearServerOverrides: (serverId: string) => void; + migrateServerOverrides: (servers: ServerProfile[]) => void; + getStrategyForServer: (serverId: string | null | undefined) => AnalyticsStrategy; + getAdvancedParallelismForServer: (serverId: string | null | undefined) => number; +} + +export const useAnalysisStrategyStore = create()( + persist( + (set, get) => ({ + strategy: DEFAULT_ANALYTICS_STRATEGY, + advancedParallelism: DEFAULT_ADVANCED_PARALLELISM, + strategyByServer: {}, + advancedParallelismByServer: {}, + setStrategy: strategy => set({ strategy }), + setAdvancedParallelism: workers => + set({ advancedParallelism: clampAdvancedParallelism(workers) }), + setServerStrategy: (serverId, strategy) => + set(s => ({ + strategyByServer: { ...s.strategyByServer, [resolveStrategyKey(serverId)]: strategy }, + })), + setServerAdvancedParallelism: (serverId, workers) => + set(s => ({ + advancedParallelismByServer: { + ...s.advancedParallelismByServer, + [resolveStrategyKey(serverId)]: clampAdvancedParallelism(workers), + }, + })), + clearServerOverrides: (serverId) => + set(s => { + const key = resolveStrategyKey(serverId); + const { [serverId]: _, [key]: __, ...strategyByServer } = s.strategyByServer; + const { [serverId]: ___, [key]: ____, ...advancedParallelismByServer } = s.advancedParallelismByServer; + return { strategyByServer, advancedParallelismByServer }; + }), + migrateServerOverrides: (servers) => + set(s => { + if (servers.length === 0) return {}; + let changed = false; + const strategyByServer = { ...s.strategyByServer }; + const advancedParallelismByServer = { ...s.advancedParallelismByServer }; + for (const server of servers) { + const key = serverIndexKeyFromUrl(server.url) || server.id; + if (key === server.id) continue; + const legacyStrategy = strategyByServer[server.id]; + const nextStrategy = strategyByServer[key]; + if (legacyStrategy !== undefined && nextStrategy !== undefined) { + delete strategyByServer[server.id]; + changed = true; + } else if (legacyStrategy !== undefined && nextStrategy === undefined) { + strategyByServer[key] = legacyStrategy; + delete strategyByServer[server.id]; + changed = true; + } + + const legacyParallel = advancedParallelismByServer[server.id]; + const nextParallel = advancedParallelismByServer[key]; + if (legacyParallel !== undefined && nextParallel !== undefined) { + delete advancedParallelismByServer[server.id]; + changed = true; + } else if (legacyParallel !== undefined && nextParallel === undefined) { + advancedParallelismByServer[key] = legacyParallel; + delete advancedParallelismByServer[server.id]; + changed = true; + } + } + return changed ? { strategyByServer, advancedParallelismByServer } : {}; + }), + getStrategyForServer: serverId => { + if (!serverId) return DEFAULT_ANALYTICS_STRATEGY; + const key = resolveStrategyKey(serverId); + return get().strategyByServer[key] ?? get().strategyByServer[serverId] ?? get().strategy; + }, + getAdvancedParallelismForServer: serverId => { + if (!serverId) return DEFAULT_ADVANCED_PARALLELISM; + const key = resolveStrategyKey(serverId); + return get().advancedParallelismByServer[key] + ?? get().advancedParallelismByServer[serverId] + ?? get().advancedParallelism; + }, + }), + { + name: 'psysonic-analytics-strategy', + storage: createJSONStorage(() => localStorage), + version: 1, + migrate: (persisted, version) => { + const fallback = { + strategy: DEFAULT_ANALYTICS_STRATEGY, + advancedParallelism: DEFAULT_ADVANCED_PARALLELISM, + strategyByServer: {} as Record, + advancedParallelismByServer: {} as Record, + }; + if (version < 1) { + const old = persisted as { + strategy?: AnalyticsStrategy; + advancedParallelism?: number; + }; + return { + strategy: old.strategy ?? fallback.strategy, + advancedParallelism: clampAdvancedParallelism(old.advancedParallelism ?? fallback.advancedParallelism), + strategyByServer: fallback.strategyByServer, + advancedParallelismByServer: fallback.advancedParallelismByServer, + }; + } + const current = persisted as Partial; + return { + strategy: current.strategy ?? fallback.strategy, + advancedParallelism: clampAdvancedParallelism(current.advancedParallelism ?? fallback.advancedParallelism), + strategyByServer: current.strategyByServer ?? fallback.strategyByServer, + advancedParallelismByServer: current.advancedParallelismByServer ?? fallback.advancedParallelismByServer, + }; + }, + partialize: s => ({ + strategy: s.strategy, + advancedParallelism: s.advancedParallelism, + strategyByServer: s.strategyByServer, + advancedParallelismByServer: s.advancedParallelismByServer, + }), + }, + ), +); diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index 118f2c0d..23b582bc 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -12,7 +12,11 @@ import { } from './playListenSession'; import { getPerfProbeFlags } from '../utils/perf/perfFlags'; import { bumpPerfCounter } from '../utils/perf/perfTelemetry'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { + getPlaybackCacheServerKey, + getPlaybackIndexKey, + getPlaybackServerId, +} from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { showToast } from '../utils/ui/toast'; @@ -266,7 +270,8 @@ export function handleAudioProgress( const shouldBytePreloadForGaplessBackup = gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0; - const serverId = getPlaybackServerId(); + const serverId = getPlaybackCacheServerKey(); + const analysisServerId = getPlaybackIndexKey(); const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId); const nextIsLocalFile = nextUrl.startsWith('psysonic-local://'); @@ -295,7 +300,7 @@ export function handleAudioProgress( url: nextUrl, durationHint: nextTrack.duration, analysisTrackId: nextTrack.id, - serverId: serverId || null, + serverId: analysisServerId || null, }).catch(() => {}); } @@ -328,7 +333,7 @@ export function handleAudioProgress( fallbackDb: authState.replayGainFallbackDb, hiResEnabled: authState.enableHiRes, analysisTrackId: nextTrack.id, - serverId: serverId || null, + serverId: analysisServerId || null, }).catch(() => {}); } } @@ -363,7 +368,7 @@ export function handleAudioEnded(): void { void (async () => { if (repeatMode === 'one' && currentTrack) { const authState = useAuthStore.getState(); - const repeatPromoteSid = getPlaybackServerId(); + const repeatPromoteSid = getPlaybackCacheServerKey(); if (authState.hotCacheEnabled && repeatPromoteSid) { // Same-track repeat never hit `playTrack`'s prev→promote path; flush // Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local. @@ -416,7 +421,7 @@ export function handleAudioTrackSwitched(_duration: number): void { void playListenSessionOnTrackSwitched(nextTrack); - const switchServerId = getPlaybackServerId(); + const switchServerId = getPlaybackCacheServerKey(); const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId); const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl); @@ -459,7 +464,7 @@ export function handleAudioTrackSwitched(_duration: number): void { }); } syncQueueToServer(queue, nextTrack, 0); - touchHotCacheOnPlayback(nextTrack.id, getPlaybackServerId()); + touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey()); } export function handleAudioError(message: string): void { diff --git a/src/store/libraryIndexStore.ts b/src/store/libraryIndexStore.ts index fe42730a..3c5a3602 100644 --- a/src/store/libraryIndexStore.ts +++ b/src/store/libraryIndexStore.ts @@ -1,89 +1,31 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -type PersistedV0 = { - indexEnabledByServer?: Record; - autoReconcileEnabled?: boolean; -}; - /** * Settings for the local library index (spec §7.3). - * Master toggle indexes all configured servers; per-server exclusion opt-out. + * Index is always on for configured servers; sync controls live under Settings → Servers. */ interface LibraryIndexState { + /** Always true (kept for persisted-state migration and existing call sites). */ masterEnabled: boolean; - /** `serverId → true` excludes that server while master is on. */ - syncExcludedByServer: Record; - autoReconcileEnabled: boolean; - setMasterEnabled: (enabled: boolean) => void; - /** Legacy API — enables master and clears exclusion, or excludes one server. */ - setIndexEnabled: (serverId: string, enabled: boolean) => void; - setServerSyncExcluded: (serverId: string, excluded: boolean) => void; - setAutoReconcileEnabled: (enabled: boolean) => void; isIndexEnabled: (serverId: string | null | undefined) => boolean; indexedServerIds: (allServerIds: string[]) => string[]; } export const useLibraryIndexStore = create()( persist( - (set, get) => ({ - masterEnabled: false, - syncExcludedByServer: {}, - autoReconcileEnabled: true, - setMasterEnabled: enabled => set({ masterEnabled: enabled }), - setIndexEnabled: (serverId, enabled) => { - if (enabled) { - set(s => { - const { [serverId]: _omit, ...syncExcludedByServer } = s.syncExcludedByServer; - return { masterEnabled: true, syncExcludedByServer }; - }); - } else { - set(s => ({ - syncExcludedByServer: { ...s.syncExcludedByServer, [serverId]: true }, - })); - } - }, - setServerSyncExcluded: (serverId, excluded) => { - if (excluded) { - set(s => ({ - syncExcludedByServer: { ...s.syncExcludedByServer, [serverId]: true }, - })); - } else { - set(s => { - const { [serverId]: _omit, ...syncExcludedByServer } = s.syncExcludedByServer; - return { syncExcludedByServer }; - }); - } - }, - setAutoReconcileEnabled: enabled => set({ autoReconcileEnabled: enabled }), - isIndexEnabled: serverId => { - if (!serverId || !get().masterEnabled) return false; - return get().syncExcludedByServer[serverId] !== true; - }, - indexedServerIds: allServerIds => { - if (!get().masterEnabled) return []; - return allServerIds.filter(id => get().syncExcludedByServer[id] !== true); - }, + (_set, get) => ({ + masterEnabled: true, + isIndexEnabled: serverId => !!serverId && get().masterEnabled, + indexedServerIds: allServerIds => (get().masterEnabled ? allServerIds : []), }), { name: 'psysonic-library-index', - version: 1, + version: 2, storage: createJSONStorage(() => localStorage), - migrate: (persisted, version) => { - if (version < 1) { - const old = persisted as PersistedV0; - const masterEnabled = Object.values(old.indexEnabledByServer ?? {}).some(v => v === true); - return { - masterEnabled, - syncExcludedByServer: {}, - autoReconcileEnabled: old.autoReconcileEnabled ?? true, - }; - } - return persisted as { - masterEnabled: boolean; - syncExcludedByServer: Record; - autoReconcileEnabled: boolean; - }; + migrate: (persisted, _version) => { + const previous = persisted as { masterEnabled?: boolean } | undefined; + return { masterEnabled: previous?.masterEnabled ?? true }; }, }, ), diff --git a/src/store/loudnessBackfillWindow.ts b/src/store/loudnessBackfillWindow.ts index 8dcc2be6..393a5be2 100644 --- a/src/store/loudnessBackfillWindow.ts +++ b/src/store/loudnessBackfillWindow.ts @@ -45,3 +45,42 @@ export function collectLoudnessBackfillWindowTrackIds( } return Array.from(ids); } + +/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */ +export function collectPlaybackMiddlePriorityTrackIds( + queue: Track[], + queueIndex: number, + currentTrack: Track | null, + preloadMode: 'off' | 'balanced' | 'early' | 'custom', +): string[] { + const ids = new Set(); + const start = Math.max(0, queueIndex + 1); + const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); + for (let i = start; i < end; i++) { + const tid = queue[i]?.id; + if (tid && tid !== currentTrack?.id) ids.add(tid); + } + if (preloadMode !== 'off') { + const nextIdx = queueIndex + 1; + if (nextIdx >= 0 && nextIdx < queue.length) { + const tid = queue[nextIdx]?.id; + if (tid && tid !== currentTrack?.id) ids.add(tid); + } + } + return Array.from(ids); +} + +export function loudnessBackfillPriorityForTrack( + trackId: string, + queue: Track[], + queueIndex: number, + currentTrack: Track | null, +): 'high' | 'middle' | 'low' { + if (currentTrack?.id === trackId) return 'high'; + const start = Math.max(0, queueIndex + 1); + const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); + for (let i = start; i < end; i++) { + if (queue[i]?.id === trackId) return 'middle'; + } + return 'low'; +} diff --git a/src/store/loudnessRefresh.test.ts b/src/store/loudnessRefresh.test.ts index a6b9e609..540c04b5 100644 --- a/src/store/loudnessRefresh.test.ts +++ b/src/store/loudnessRefresh.test.ts @@ -63,6 +63,7 @@ vi.mock('./loudnessBackfillState', () => ({ vi.mock('./loudnessBackfillWindow', () => ({ LOUDNESS_BACKFILL_WINDOW_AHEAD: 5, isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock, + loudnessBackfillPriorityForTrack: vi.fn(() => 'middle'), })); import { diff --git a/src/store/loudnessRefresh.ts b/src/store/loudnessRefresh.ts index 50301222..694097d0 100644 --- a/src/store/loudnessRefresh.ts +++ b/src/store/loudnessRefresh.ts @@ -1,6 +1,6 @@ import { buildStreamUrl } from '../api/subsonicStreamUrl'; import { invoke } from '@tauri-apps/api/core'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { getPlaybackIndexKey } from '../utils/playback/playbackServer'; import { redactSubsonicUrlForLog } from '../utils/server/redactSubsonicUrl'; import { useAuthStore } from './authStore'; import { usePlayerStore } from './playerStore'; @@ -20,6 +20,7 @@ import { import { LOUDNESS_BACKFILL_WINDOW_AHEAD, isTrackInsideLoudnessBackfillWindow, + loudnessBackfillPriorityForTrack, } from './loudnessBackfillWindow'; /** Subsonic-server loudness-cache row as Rust hands it back. */ @@ -70,7 +71,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId }); try { const requestedTarget = useAuthStore.getState().loudnessTargetLufs; - const serverId = getPlaybackServerId() || null; + const serverId = getPlaybackIndexKey() || null; const row = await invoke('analysis_get_loudness_for_track', { trackId, targetLufs: requestedTarget, @@ -100,12 +101,19 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): } markBackfillInFlight(trackId, attempts + 1); const url = buildStreamUrl(trackId); + const priority = loudnessBackfillPriorityForTrack( + trackId, + live.queue, + live.queueIndex, + live.currentTrack, + ); emitNormalizationDebug('backfill:enqueue', { trackId, url: redactSubsonicUrlForLog(url), attempt: attempts + 1, + priority, }); - void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId }) + void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId, priority }) .then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 })) .catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) })) .finally(() => { diff --git a/src/store/loudnessReseed.ts b/src/store/loudnessReseed.ts index afbae031..a62022fa 100644 --- a/src/store/loudnessReseed.ts +++ b/src/store/loudnessReseed.ts @@ -1,6 +1,6 @@ import { buildStreamUrl } from '../api/subsonicStreamUrl'; import { invoke } from '@tauri-apps/api/core'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { getPlaybackIndexKey } from '../utils/playback/playbackServer'; import { useAuthStore } from './authStore'; import { usePlayerStore } from './playerStore'; import { bumpWaveformRefreshGen } from './waveformRefreshGen'; @@ -43,7 +43,7 @@ export async function reseedLoudnessForTrackId(trackId: string): Promise { normalizationTargetLufs: auth.loudnessTargetLufs, normalizationEngineLive: 'loudness', }); - const serverId = getPlaybackServerId() || null; + const serverId = getPlaybackIndexKey() || null; try { await invoke('analysis_delete_waveform_for_track', { trackId, serverId }); } catch (e) { diff --git a/src/store/migrationStore.ts b/src/store/migrationStore.ts new file mode 100644 index 00000000..5109b420 --- /dev/null +++ b/src/store/migrationStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import type { MigrationInspectReport, MigrationProgressEvent } from '../api/migration'; + +export type MigrationPhase = 'idle' | 'inspecting' | 'running' | 'completed' | 'error'; + +interface MigrationState { + phase: MigrationPhase; + needsMigration: boolean; + inspect: MigrationInspectReport | null; + progress: MigrationProgressEvent | null; + lastError: string | null; + setPhase: (phase: MigrationPhase) => void; + setNeedsMigration: (needsMigration: boolean) => void; + setInspect: (report: MigrationInspectReport | null) => void; + setProgress: (event: MigrationProgressEvent | null) => void; + setError: (error: string | null) => void; +} + +export const useMigrationStore = create(set => ({ + phase: 'idle', + needsMigration: false, + inspect: null, + progress: null, + lastError: null, + setPhase: phase => set({ phase }), + setNeedsMigration: needsMigration => set({ needsMigration }), + setInspect: inspect => set({ inspect }), + setProgress: progress => set({ progress }), + setError: lastError => set({ lastError }), +})); diff --git a/src/store/playListenSession.test.ts b/src/store/playListenSession.test.ts index 5d586d47..9a1f7b5d 100644 --- a/src/store/playListenSession.test.ts +++ b/src/store/playListenSession.test.ts @@ -37,7 +37,6 @@ describe('playListenSession', () => { _resetPlayListenSessionForTest(); useLibraryIndexStore.setState({ masterEnabled: true, - syncExcludedByServer: {}, }); usePlayerStore.setState({ currentRadio: null, diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 60a55dc8..b02621b6 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -6,6 +6,8 @@ import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { sameQueueTrackId } from '../utils/playback/queueIdentity'; import { bindQueueServerForPlayback, + getPlaybackCacheServerKey, + getPlaybackIndexKey, getPlaybackServerId, shouldBindQueueServerForPlay, } from '../utils/playback/playbackServer'; @@ -218,19 +220,20 @@ export function runPlayTrack( prevTrack && sameQueueTrackId(prevTrack.id, track.id) && authState.hotCacheEnabled - && getPlaybackServerId(), + && getPlaybackCacheServerKey(), ); const runPlayTrackBody = () => { const authStateNow = useAuthStore.getState(); const playbackSid = getPlaybackServerId(); - const url = resolvePlaybackUrl(track.id, playbackSid); + const playbackCacheSid = getPlaybackCacheServerKey(); + const url = resolvePlaybackUrl(track.id, playbackCacheSid); recordEnginePlayUrl(track.id, url); const preloadedTrackId = get().enginePreloadedTrackId; const keepPreloadHint = preloadedTrackId === track.id; const playbackSourceHint = playbackSourceHintForResolvedUrl( track.id, - playbackSid, + playbackCacheSid, url, ); if (import.meta.env.DEV) { @@ -270,7 +273,7 @@ export function runPlayTrack( && !sameQueueTrackId(prevTrack.id, track.id) && authStateNow.hotCacheEnabled ) { - const prevPromoteSid = getPlaybackServerId(); + const prevPromoteSid = getPlaybackCacheServerKey(); if (prevPromoteSid) { void promoteCompletedStreamToHotCache( prevTrack, @@ -301,7 +304,7 @@ export function runPlayTrack( manual, hiResEnabled: authStateNow.enableHiRes, analysisTrackId: track.id, - serverId: getPlaybackServerId() || null, + serverId: getPlaybackIndexKey() || null, streamFormatSuffix: track.suffix ?? null, }) .then(() => { @@ -354,10 +357,10 @@ export function runPlayTrack( }); } syncQueueToServer(newQueue, track, initialTime); - touchHotCacheOnPlayback(track.id, playbackSid); + touchHotCacheOnPlayback(track.id, playbackCacheSid); }; - const hotPromoteSid = getPlaybackServerId(); + const hotPromoteSid = getPlaybackCacheServerKey(); if (needSameTrackHotPromote && hotPromoteSid) { void promoteCompletedStreamToHotCache( track, diff --git a/src/store/queueUndoAudioRestore.test.ts b/src/store/queueUndoAudioRestore.test.ts index 39e9f498..8e1a8463 100644 --- a/src/store/queueUndoAudioRestore.test.ts +++ b/src/store/queueUndoAudioRestore.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => { const auth = { activeServerId: 'srv', + servers: [], replayGainMode: 'track' as 'track' | 'album', replayGainPreGainDb: 0, replayGainFallbackDb: -6, diff --git a/src/store/queueUndoAudioRestore.ts b/src/store/queueUndoAudioRestore.ts index 99acfdd6..1a2da22e 100644 --- a/src/store/queueUndoAudioRestore.ts +++ b/src/store/queueUndoAudioRestore.ts @@ -1,7 +1,11 @@ import type { Track } from './playerStoreTypes'; import { invoke } from '@tauri-apps/api/core'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { + getPlaybackCacheServerKey, + getPlaybackIndexKey, + getPlaybackServerId, +} from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { useAuthStore } from './authStore'; @@ -40,10 +44,12 @@ export function queueUndoRestoreAudioEngine(opts: { ); const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null; const playbackSid = getPlaybackServerId(); - const url = resolvePlaybackUrl(track.id, playbackSid); + const playbackCacheSid = getPlaybackCacheServerKey(); + const playbackIndexKey = getPlaybackIndexKey(); + const url = resolvePlaybackUrl(track.id, playbackCacheSid); recordEnginePlayUrl(track.id, url); usePlayerStore.setState({ - currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackSid, url), + currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url), }); const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id; setDeferHotCachePrefetch(true); @@ -59,7 +65,7 @@ export function queueUndoRestoreAudioEngine(opts: { manual: false, hiResEnabled: authState.enableHiRes, analysisTrackId: track.id, - serverId: playbackSid || null, + serverId: playbackIndexKey || null, streamFormatSuffix: track.suffix ?? null, }) .then(() => { @@ -94,5 +100,5 @@ export function queueUndoRestoreAudioEngine(opts: { .finally(() => { setDeferHotCachePrefetch(false); }); - touchHotCacheOnPlayback(track.id, getPlaybackServerId()); + touchHotCacheOnPlayback(track.id, playbackCacheSid); } diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index 7605f785..317c36f2 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -2,7 +2,11 @@ import { getSong } from '../api/subsonicLibrary'; import { invoke } from '@tauri-apps/api/core'; import { estimateLivePosition } from '../api/orbit'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { + getPlaybackCacheServerKey, + getPlaybackIndexKey, + getPlaybackServerId, +} from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { songToTrack } from '../utils/playback/songToTrack'; @@ -118,7 +122,7 @@ export function runResume(set: SetState, get: GetState): void { invoke('audio_resume').catch(console.error); setIsAudioPaused(false); set({ isPlaying: true }); - touchHotCacheOnPlayback(currentTrack.id, getPlaybackServerId()); + touchHotCacheOnPlayback(currentTrack.id, getPlaybackCacheServerKey()); } else { // Engine has no loaded paused stream (app relaunch, or track ended and user // hits play — `isAudioPaused` is false after `audio:ended`). Flush any @@ -129,7 +133,7 @@ export function runResume(set: SetState, get: GetState): void { void (async () => { const authHot = useAuthStore.getState(); - const resumePromoteSid = getPlaybackServerId(); + const resumePromoteSid = getPlaybackCacheServerKey(); if (authHot.hotCacheEnabled && resumePromoteSid) { await promoteCompletedStreamToHotCache( currentTrack, @@ -151,7 +155,7 @@ export function runResume(set: SetState, get: GetState): void { isReplayGainActive(), authStateCold.replayGainMode, ); const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null; - const coldServerId = getPlaybackServerId(); + const coldServerId = getPlaybackIndexKey(); setDeferHotCachePrefetch(true); const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId); set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) }); @@ -191,7 +195,7 @@ export function runResume(set: SetState, get: GetState): void { isReplayGainActive(), authStateCold.replayGainMode, ); const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null; - const coldServerId = getPlaybackServerId(); + const coldServerId = getPlaybackIndexKey(); setDeferHotCachePrefetch(true); const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId); set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) }); diff --git a/src/store/waveformRefresh.ts b/src/store/waveformRefresh.ts index bbebbd6a..d937c007 100644 --- a/src/store/waveformRefresh.ts +++ b/src/store/waveformRefresh.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { coerceWaveformBins } from '../utils/waveform/waveformParse'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { getPlaybackIndexKey } from '../utils/playback/playbackServer'; import { usePlayerStore } from './playerStore'; import { getWaveformRefreshGen } from './waveformRefreshGen'; @@ -28,7 +28,7 @@ export async function refreshWaveformForTrack(trackId: string): Promise { try { const row = await invoke('analysis_get_waveform_for_track', { trackId, - serverId: getPlaybackServerId() || null, + serverId: getPlaybackIndexKey() || null, }); if (getWaveformRefreshGen(trackId) !== gen) return; // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). diff --git a/src/styles/components/orbit-session-top-strip.css b/src/styles/components/orbit-session-top-strip.css index 6b671d22..8ce0661c 100644 --- a/src/styles/components/orbit-session-top-strip.css +++ b/src/styles/components/orbit-session-top-strip.css @@ -1524,6 +1524,21 @@ background: color-mix(in srgb, var(--ctp-crust, #111) 82%, transparent); border: 1px solid color-mix(in srgb, var(--border-subtle, #444) 70%, transparent); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.25; +} + +.fps-overlay__row--detail { + font-size: 11px; + color: var(--text-muted, #aaa); +} + +.fps-overlay__row--steps { + font-size: 10px; + color: var(--text-muted, #888); + letter-spacing: 0.01em; } .fps-overlay__unit { diff --git a/src/utils/export/backup.ts b/src/utils/export/backup.ts index 65ef31e7..569221db 100644 --- a/src/utils/export/backup.ts +++ b/src/utils/export/backup.ts @@ -1,8 +1,11 @@ import { save, open as openDialog } from '@tauri-apps/plugin-dialog'; import { writeFile, readTextFile } from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; import { version as appVersion } from '../../../package.json'; const BACKUP_VERSION = 1; +export type ImportedBackupKind = 'config' | 'databases' | 'full'; +export type BackupExportMode = 'full' | 'library' | 'config'; const BACKUP_KEYS = [ 'psysonic-auth', @@ -17,7 +20,7 @@ const BACKUP_KEYS = [ 'psysonic_home', ]; -export async function exportBackup(): Promise { +function collectStores(): Record { const stores: Record = {}; for (const key of BACKUP_KEYS) { const val = localStorage.getItem(key); @@ -29,24 +32,98 @@ export async function exportBackup(): Promise { } } } + return stores; +} - const manifest = { +function buildSettingsManifest() { + return { version: BACKUP_VERSION, app_version: appVersion, created_at: new Date().toISOString(), - stores, + stores: collectStores(), }; +} +export async function pickBackupExportPath(mode: BackupExportMode): Promise { const today = new Date().toISOString().slice(0, 10); - const path = await save({ + if (mode === 'full') { + return save({ + filters: [{ name: 'Psysonic Full Backup', extensions: ['psyfull', 'zip'] }], + defaultPath: `psysonic-full-${today}.psyfull`, + }); + } + if (mode === 'library') { + return save({ + filters: [{ name: 'Psysonic Library Databases Archive', extensions: ['psylib', 'zip'] }], + defaultPath: `psysonic-library-databases-${today}.psylib`, + }); + } + return save({ filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }], defaultPath: `psysonic-backup-${today}.psybkp`, }); +} - if (!path) return null; - - const content = JSON.stringify(manifest, null, 2); +export async function exportBackupToPath(mode: BackupExportMode, path: string): Promise { + if (mode === 'full') { + await invoke('backup_export_full', { + destinationPath: path, + stores: collectStores(), + appVersion, + }); + return; + } + if (mode === 'library') { + await invoke('backup_export_library_db', { destinationPath: path }); + return; + } + const content = JSON.stringify(buildSettingsManifest(), null, 2); await writeFile(path, new TextEncoder().encode(content)); +} + +export async function pickBackupImportPath(): Promise { + const path = await openDialog({ + filters: [{ name: 'Psysonic Backup', extensions: ['psybkp', 'psylib', 'psyfull', 'zip'] }], + multiple: false, + title: 'Import Backup', + }); + return path && typeof path === 'string' ? path : null; +} + +export async function importAnyBackupFromPath(path: string): Promise { + try { + const raw = await readTextFile(path); + const manifest = JSON.parse(raw); + if (typeof manifest.version === 'number' && manifest.stores && typeof manifest.stores === 'object') { + for (const [key, value] of Object.entries(manifest.stores as Record)) { + localStorage.setItem(key, JSON.stringify(value)); + } + window.location.reload(); + return 'config'; + } + } catch { + // Not a plain JSON settings backup, continue detection. + } + + try { + const stores = await invoke>('backup_import_full', { sourcePath: path }); + for (const [key, value] of Object.entries(stores)) { + localStorage.setItem(key, JSON.stringify(value)); + } + window.location.reload(); + return 'full'; + } catch { + // Not a full backup archive, continue detection. + } + + await invoke('backup_import_library_db', { sourcePath: path }); + return 'databases'; +} + +export async function exportBackup(): Promise { + const path = await pickBackupExportPath('config'); + if (!path) return null; + await exportBackupToPath('config', path); return path; } @@ -72,3 +149,47 @@ export async function importBackup(): Promise { window.location.reload(); } + +export async function exportLibraryDatabaseBackup(): Promise { + const path = await pickBackupExportPath('library'); + if (!path) return null; + await exportBackupToPath('library', path); + return path; +} + +export async function importLibraryDatabaseBackup(): Promise { + const path = await openDialog({ + filters: [{ name: 'Psysonic Library Databases Archive', extensions: ['psylib', 'zip'] }], + multiple: false, + title: 'Import Library Databases Archive', + }); + if (!path || typeof path !== 'string') return; + await invoke('backup_import_library_db', { sourcePath: path }); +} + +export async function exportFullBackup(): Promise { + const path = await pickBackupExportPath('full'); + if (!path) return null; + await exportBackupToPath('full', path); + return path; +} + +export async function importFullBackup(): Promise { + const path = await openDialog({ + filters: [{ name: 'Psysonic Full Backup', extensions: ['psyfull', 'zip'] }], + multiple: false, + title: 'Import Full Backup', + }); + if (!path || typeof path !== 'string') return; + const stores = await invoke>('backup_import_full', { sourcePath: path }); + for (const [key, value] of Object.entries(stores)) { + localStorage.setItem(key, JSON.stringify(value)); + } + window.location.reload(); +} + +export async function importAnyBackup(): Promise { + const path = await pickBackupImportPath(); + if (!path) return null; + return importAnyBackupFromPath(path); +} diff --git a/src/utils/library/advancedSearchLocal.test.ts b/src/utils/library/advancedSearchLocal.test.ts index 703b69bd..f504305d 100644 --- a/src/utils/library/advancedSearchLocal.test.ts +++ b/src/utils/library/advancedSearchLocal.test.ts @@ -28,7 +28,7 @@ const ready = () => describe('runLocalAdvancedSearch', () => { beforeEach(() => { - useLibraryIndexStore.getState().setIndexEnabled('s1', true); + useLibraryIndexStore.setState({ masterEnabled: true }); }); it('returns null (→ network fallback) when the index is not ready', async () => { @@ -38,7 +38,7 @@ describe('runLocalAdvancedSearch', () => { }); it('returns null when the index is disabled for the server', async () => { - useLibraryIndexStore.getState().setIndexEnabled('s1', false); + useLibraryIndexStore.setState({ masterEnabled: false }); const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100); expect(res).toBeNull(); }); @@ -155,7 +155,7 @@ describe('runLocalAdvancedSearch', () => { describe('runLocalSongBrowse', () => { beforeEach(() => { - useLibraryIndexStore.getState().setIndexEnabled('s1', true); + useLibraryIndexStore.setState({ masterEnabled: true }); }); it('returns null for a missing server id (→ network browse)', async () => { diff --git a/src/utils/library/analysisStrategy.ts b/src/utils/library/analysisStrategy.ts new file mode 100644 index 00000000..23de2a61 --- /dev/null +++ b/src/utils/library/analysisStrategy.ts @@ -0,0 +1,17 @@ +export const ANALYTICS_STRATEGIES = ['lazy', 'advanced'] as const; + +export type AnalyticsStrategy = (typeof ANALYTICS_STRATEGIES)[number]; + +export const DEFAULT_ANALYTICS_STRATEGY: AnalyticsStrategy = 'lazy'; + +export const ADVANCED_PARALLELISM_MIN = 1; +export const ADVANCED_PARALLELISM_MAX = 20; +export const DEFAULT_ADVANCED_PARALLELISM = 1; + +export function clampAdvancedParallelism(value: number): number { + const rounded = Math.round(value); + return Math.min( + ADVANCED_PARALLELISM_MAX, + Math.max(ADVANCED_PARALLELISM_MIN, rounded), + ); +} diff --git a/src/utils/library/libraryAnalysisBackfillPolicy.test.ts b/src/utils/library/libraryAnalysisBackfillPolicy.test.ts new file mode 100644 index 00000000..3f274516 --- /dev/null +++ b/src/utils/library/libraryAnalysisBackfillPolicy.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { + computeLibraryBackfillTargetDepth, + libraryBackfillNeedsTopUp, + libraryBackfillPipelineBacklog, + libraryBackfillTopUpLimit, +} from './libraryAnalysisBackfillPolicy'; + +describe('libraryAnalysisBackfillPolicy', () => { + it('targets workers × 3 with floor and cap', () => { + expect(computeLibraryBackfillTargetDepth(1)).toBe(8); + expect(computeLibraryBackfillTargetDepth(4)).toBe(12); + expect(computeLibraryBackfillTargetDepth(8)).toBe(24); + expect(computeLibraryBackfillTargetDepth(100)).toBe(240); + }); + + it('measures backlog as queued plus active downloads', () => { + expect( + libraryBackfillPipelineBacklog({ httpQueued: 5, httpDownloadActive: 3 }), + ).toBe(8); + }); + + it('requests top-up while backlog stays below target', () => { + const stats = { httpQueued: 2, httpDownloadActive: 1 }; + expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(true); + expect(libraryBackfillTopUpLimit(stats, 8)).toBe(20); + }); + + it('stops top-up when backlog meets target', () => { + const stats = { httpQueued: 20, httpDownloadActive: 4 }; + expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(false); + expect(libraryBackfillTopUpLimit(stats, 8)).toBe(0); + }); +}); diff --git a/src/utils/library/libraryAnalysisBackfillPolicy.ts b/src/utils/library/libraryAnalysisBackfillPolicy.ts new file mode 100644 index 00000000..8ac9cb72 --- /dev/null +++ b/src/utils/library/libraryAnalysisBackfillPolicy.ts @@ -0,0 +1,39 @@ +import type { AnalysisPipelineQueueStatsDto } from '../../api/analysis'; +import { LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE } from '../../api/analysis'; + +/** Target backlog ≈ workers × multiplier (min floor, max cap). */ +export const LIBRARY_BACKLOG_DEPTH_MULTIPLIER = 3; +export const LIBRARY_BACKLOG_MIN = 8; +export const LIBRARY_BACKLOG_MAX = 240; + +export function computeLibraryBackfillTargetDepth(workers: number): number { + const w = Math.max(1, Math.round(workers)); + return Math.min( + LIBRARY_BACKLOG_MAX, + Math.max(LIBRARY_BACKLOG_MIN, w * LIBRARY_BACKLOG_DEPTH_MULTIPLIER), + ); +} + +/** HTTP jobs waiting or actively downloading (keeps workers fed after decode decoupling). */ +export function libraryBackfillPipelineBacklog( + stats: Pick, +): number { + return stats.httpQueued + stats.httpDownloadActive; +} + +export function libraryBackfillNeedsTopUp( + stats: Pick, + workers: number, +): boolean { + return libraryBackfillPipelineBacklog(stats) < computeLibraryBackfillTargetDepth(workers); +} + +export function libraryBackfillTopUpLimit( + stats: Pick, + workers: number, +): number { + const target = computeLibraryBackfillTargetDepth(workers); + const deficit = target - libraryBackfillPipelineBacklog(stats); + if (deficit <= 0) return 0; + return Math.min(LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE, deficit); +} diff --git a/src/utils/library/librarySession.ts b/src/utils/library/librarySession.ts index a26713a2..71d4a424 100644 --- a/src/utils/library/librarySession.ts +++ b/src/utils/library/librarySession.ts @@ -8,6 +8,7 @@ import type { ServerProfile } from '../../store/authStoreTypes'; import { useAuthStore } from '../../store/authStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { serverProfileBaseUrl } from '../server/serverBaseUrl'; +import { serverIndexKeyForProfile } from '../server/serverIndexKey'; import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog'; export type BindServerResult = 'bound' | 'offline' | 'error'; @@ -60,7 +61,8 @@ export async function bindIndexedServer(server: ServerProfile): Promise { const bound = await bindIndexedServer(server); if (bound !== 'bound') return bound; - await queueInitialSyncIfNeeded(server.id); + const indexKey = serverIndexKeyForProfile(server); + await queueInitialSyncIfNeeded(indexKey); return 'bound'; } @@ -68,14 +70,29 @@ export async function bootstrapIndexedServer(server: ServerProfile): Promise> { const lib = useLibraryIndexStore.getState(); if (!lib.masterEnabled) return {}; - const indexed = useAuthStore.getState().servers.filter(s => lib.isIndexEnabled(s.id)); - const results: Record = {}; + const auth = useAuthStore.getState(); + const active = auth.activeServerId + ? auth.servers.find(s => s.id === auth.activeServerId) ?? null + : null; + const indexed = auth.servers.filter(s => lib.isIndexEnabled(s.id)); + const primaryByKey = new Map(); for (const server of indexed) { - results[server.id] = await bindIndexedServer(server); + const key = serverIndexKeyForProfile(server); + if (!primaryByKey.has(key)) primaryByKey.set(key, server); } - for (const server of indexed) { - if (results[server.id] === 'bound') { - await queueInitialSyncIfNeeded(server.id); + if (active) { + const key = serverIndexKeyForProfile(active); + if (primaryByKey.has(key)) primaryByKey.set(key, active); + } + const results: Record = {}; + for (const server of primaryByKey.values()) { + const key = serverIndexKeyForProfile(server); + results[key] = await bindIndexedServer(server); + } + for (const server of primaryByKey.values()) { + const key = serverIndexKeyForProfile(server); + if (results[key] === 'bound') { + await queueInitialSyncIfNeeded(key); } } return results; diff --git a/src/utils/library/librarySyncQueue.ts b/src/utils/library/librarySyncQueue.ts index 6aede145..d7984fa4 100644 --- a/src/utils/library/librarySyncQueue.ts +++ b/src/utils/library/librarySyncQueue.ts @@ -120,6 +120,9 @@ export function enqueueLibrarySync(args: { kind: LibrarySyncQueueKind; }): Promise { logQueue(`enqueue ${args.serverId}`, args.serverId, args.kind); + if (queue.some(item => item.serverId === args.serverId && item.kind === args.kind)) { + return Promise.resolve(); + } return new Promise((resolve, reject) => { queue.push({ ...args, resolve, reject }); void drainQueue(); @@ -130,6 +133,7 @@ export function enqueueLibrarySync(args: { export async function queueInitialSyncIfNeeded(serverId: string): Promise { try { const status = await libraryGetStatus(serverId); + if (status.syncPhase === 'initial_sync') return; if (status.syncPhase === 'ready' || status.lastFullSyncAt) return; await enqueueLibrarySync({ serverId, kind: 'full' }); } catch { diff --git a/src/utils/library/patchOnUse.test.ts b/src/utils/library/patchOnUse.test.ts index 2f614f89..4974bc6e 100644 --- a/src/utils/library/patchOnUse.test.ts +++ b/src/utils/library/patchOnUse.test.ts @@ -5,7 +5,7 @@ import { patchLibraryTrackOnUse } from './patchOnUse'; describe('patchLibraryTrackOnUse', () => { beforeEach(() => { - useLibraryIndexStore.getState().setIndexEnabled('s1', true); + useLibraryIndexStore.setState({ masterEnabled: true }); onInvoke('library_patch_track', () => undefined); }); @@ -30,7 +30,7 @@ describe('patchLibraryTrackOnUse', () => { }); it('is a no-op when the index is disabled for the server', async () => { - useLibraryIndexStore.getState().setIndexEnabled('s1', false); + useLibraryIndexStore.setState({ masterEnabled: false }); patchLibraryTrackOnUse('s1', 't1', { userRating: 4 }); await Promise.resolve(); expect(invokeMock).not.toHaveBeenCalled(); diff --git a/src/utils/library/queueRestore.test.ts b/src/utils/library/queueRestore.test.ts index 5d305681..ac0cba5c 100644 --- a/src/utils/library/queueRestore.test.ts +++ b/src/utils/library/queueRestore.test.ts @@ -46,7 +46,7 @@ function seedStore(over: Partial> = { describe('hydrateQueueFromIndex', () => { beforeEach(() => { - useLibraryIndexStore.getState().setIndexEnabled('s1', true); + useLibraryIndexStore.setState({ masterEnabled: true }); seedStore(); }); diff --git a/src/utils/library/queueTrackResolver.test.ts b/src/utils/library/queueTrackResolver.test.ts index bfd37d4d..3f590d39 100644 --- a/src/utils/library/queueTrackResolver.test.ts +++ b/src/utils/library/queueTrackResolver.test.ts @@ -39,7 +39,7 @@ const ref = (trackId: string, extra: Partial = {}): QueueItemRef = describe('queueTrackResolver', () => { beforeEach(() => { _resetQueueResolverForTest(); - useLibraryIndexStore.getState().setIndexEnabled('s1', true); + useLibraryIndexStore.setState({ masterEnabled: true }); usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} }); vi.restoreAllMocks(); }); diff --git a/src/utils/offline/offlineLibraryHelpers.ts b/src/utils/offline/offlineLibraryHelpers.ts index af79e90a..f1337b4d 100644 --- a/src/utils/offline/offlineLibraryHelpers.ts +++ b/src/utils/offline/offlineLibraryHelpers.ts @@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'; import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore'; import { switchActiveServer } from '../server/switchActiveServer'; import type { Track } from '../../store/playerStoreTypes'; +import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../server/serverLookup'; export function hasAnyOfflineAlbums(albums: Record): boolean { return Object.keys(albums).length > 0; @@ -45,7 +46,7 @@ export function offlineAlbumCoverArt( size: number, ): { src: string; cacheKey: string } { if (!album.coverArt) return { src: '', cacheKey: '' }; - const server = useAuthStore.getState().servers.find(s => s.id === album.serverId); + const server = findServerByIdOrIndexKey(album.serverId); if (!server) return { src: '', cacheKey: '' }; return { src: buildCoverArtUrlForServer(server.url, server.username, server.password, album.coverArt, size), @@ -56,8 +57,10 @@ export function offlineAlbumCoverArt( /** Switch active server when it differs from the album's source server (for offline play). */ export async function ensureServerForOfflineAlbum(album: OfflineAlbumMeta): Promise { const { activeServerId, servers } = useAuthStore.getState(); - if (album.serverId === activeServerId) return true; - const server = servers.find(s => s.id === album.serverId); + const resolved = resolveServerIdForIndexKey(album.serverId); + if (resolved === activeServerId) return true; + const server = servers.find(s => s.id === resolved) + ?? findServerByIdOrIndexKey(album.serverId); if (!server) return false; return switchActiveServer(server); } diff --git a/src/utils/perf/analysisPerfStore.test.ts b/src/utils/perf/analysisPerfStore.test.ts new file mode 100644 index 00000000..37884ab8 --- /dev/null +++ b/src/utils/perf/analysisPerfStore.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import { + getAnalysisTracksPerMinute, + recordAnalysisTrackPerf, + resetAnalysisPerfStateForTest, +} from './analysisPerfStore'; + +beforeEach(() => { + resetAnalysisPerfStateForTest(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('analysisPerfStore', () => { + it('records last track timings and rolling tpm', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + recordAnalysisTrackPerf({ + trackId: 't1', + fetchMs: 1000, + seedMs: 2000, + bpmMs: 500, + totalMs: 3500, + }); + vi.advanceTimersByTime(100); + expect(getAnalysisTracksPerMinute()).toBeGreaterThan(0); + }); + + it('prunes completions older than one minute from tpm window', () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000_000); + recordAnalysisTrackPerf({ + trackId: 'old', + fetchMs: 1, + seedMs: 1, + bpmMs: 1, + totalMs: 3, + }); + vi.advanceTimersByTime(61_000); + expect(getAnalysisTracksPerMinute()).toBe(0); + }); +}); diff --git a/src/utils/perf/analysisPerfStore.ts b/src/utils/perf/analysisPerfStore.ts new file mode 100644 index 00000000..aca8714d --- /dev/null +++ b/src/utils/perf/analysisPerfStore.ts @@ -0,0 +1,89 @@ +import { useSyncExternalStore } from 'react'; + +export type AnalysisTrackPerfSample = { + trackId: string; + fetchMs: number; + seedMs: number; + bpmMs: number; + totalMs: number; + at: number; +}; + +type AnalysisPerfState = { + last: AnalysisTrackPerfSample | null; + completedAt: number[]; +}; + +const WINDOW_MS = 60_000; + +let state: AnalysisPerfState = { last: null, completedAt: [] }; +const listeners = new Set<() => void>(); + +function emit(): void { + listeners.forEach(fn => fn()); +} + +function pruneCompletedAt(now: number): number[] { + const cutoff = now - WINDOW_MS; + return state.completedAt.filter(t => t >= cutoff); +} + +export function recordAnalysisTrackPerf(payload: { + trackId: string; + fetchMs: number; + seedMs: number; + bpmMs: number; + totalMs: number; +}): void { + const now = Date.now(); + const completedAt = [...pruneCompletedAt(now), now]; + state = { + completedAt, + last: { + trackId: payload.trackId, + fetchMs: payload.fetchMs, + seedMs: payload.seedMs, + bpmMs: payload.bpmMs, + totalMs: payload.totalMs, + at: now, + }, + }; + emit(); +} + +export function getAnalysisTracksPerMinute(now = Date.now()): number { + const completedAt = pruneCompletedAt(now); + if (completedAt.length === 0) return 0; + const spanMs = Math.max(1, Math.min(WINDOW_MS, now - completedAt[0])); + return (completedAt.length / spanMs) * WINDOW_MS; +} + +export function getAnalysisPerfState(): AnalysisPerfState { + return state; +} + +export function subscribeAnalysisPerf(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +/** Last completed track sample — stable reference until the next completion. */ +export function useAnalysisPerfLast(): AnalysisTrackPerfSample | null { + return useSyncExternalStore( + subscribeAnalysisPerf, + () => state.last, + () => null, + ); +} + +/** Test-only reset. */ +export function resetAnalysisPerfStateForTest(): void { + state = { last: null, completedAt: [] }; + emit(); +} + +export function formatPerfMs(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '0'; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms)}ms`; +} diff --git a/src/utils/perf/formatAnalysisQueueStats.test.ts b/src/utils/perf/formatAnalysisQueueStats.test.ts new file mode 100644 index 00000000..2a845549 --- /dev/null +++ b/src/utils/perf/formatAnalysisQueueStats.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { + formatAnalysisPipelineQueueOverlay, + formatAnalysisTierQueue, +} from './formatAnalysisQueueStats'; + +describe('formatAnalysisQueueStats', () => { + it('formats tier queue as total(high,middle,low)', () => { + expect(formatAnalysisTierQueue(20, 1, 5, 14)).toBe('20(1,5,14)'); + }); + + it('formats pipeline overlay lines with queued + active tiers', () => { + expect( + formatAnalysisPipelineQueueOverlay({ + pipelineWorkers: 8, + httpQueued: 20, + httpQueuedHigh: 1, + httpQueuedMiddle: 5, + httpQueuedLow: 14, + httpDownloadActive: 2, + httpDownloadActiveHigh: 0, + httpDownloadActiveMiddle: 1, + httpDownloadActiveLow: 1, + cpuQueued: 12, + cpuQueuedHigh: 0, + cpuQueuedMiddle: 2, + cpuQueuedLow: 10, + cpuDecodeActive: 3, + cpuDecodeActiveHigh: 0, + cpuDecodeActiveMiddle: 0, + cpuDecodeActiveLow: 3, + }), + ).toEqual([ + 'http 22(1,6,15) · dl 2/8', + 'cpu 15(0,2,13) · run 3/8', + ]); + }); + + it('shows active-only backlog when nothing is waiting in deque', () => { + expect( + formatAnalysisPipelineQueueOverlay({ + pipelineWorkers: 20, + httpQueued: 0, + httpQueuedHigh: 0, + httpQueuedMiddle: 0, + httpQueuedLow: 0, + httpDownloadActive: 15, + httpDownloadActiveHigh: 0, + httpDownloadActiveMiddle: 0, + httpDownloadActiveLow: 15, + cpuQueued: 0, + cpuQueuedHigh: 0, + cpuQueuedMiddle: 0, + cpuQueuedLow: 0, + cpuDecodeActive: 1, + cpuDecodeActiveHigh: 0, + cpuDecodeActiveMiddle: 0, + cpuDecodeActiveLow: 1, + }), + ).toEqual([ + 'http 15(0,0,15) · dl 15/20', + 'cpu 1(0,0,1) · run 1/20', + ]); + }); +}); diff --git a/src/utils/perf/formatAnalysisQueueStats.ts b/src/utils/perf/formatAnalysisQueueStats.ts new file mode 100644 index 00000000..a0911ed2 --- /dev/null +++ b/src/utils/perf/formatAnalysisQueueStats.ts @@ -0,0 +1,49 @@ +import type { AnalysisPipelineQueueStatsDto } from '../../api/analysis'; + +export function formatAnalysisTierQueue( + total: number, + high: number, + middle: number, + low: number, +): string { + return `${total}(${high},${middle},${low})`; +} + +function combineTierCounts( + queuedHigh: number, + queuedMiddle: number, + queuedLow: number, + activeHigh: number, + activeMiddle: number, + activeLow: number, +): { total: number; high: number; middle: number; low: number } { + const high = queuedHigh + activeHigh; + const middle = queuedMiddle + activeMiddle; + const low = queuedLow + activeLow; + return { total: high + middle + low, high, middle, low }; +} + +export function formatAnalysisPipelineQueueOverlay( + stats: AnalysisPipelineQueueStatsDto, +): string[] { + const http = combineTierCounts( + stats.httpQueuedHigh, + stats.httpQueuedMiddle, + stats.httpQueuedLow, + stats.httpDownloadActiveHigh, + stats.httpDownloadActiveMiddle, + stats.httpDownloadActiveLow, + ); + const cpu = combineTierCounts( + stats.cpuQueuedHigh, + stats.cpuQueuedMiddle, + stats.cpuQueuedLow, + stats.cpuDecodeActiveHigh, + stats.cpuDecodeActiveMiddle, + stats.cpuDecodeActiveLow, + ); + const w = stats.pipelineWorkers; + const httpLine = `http ${formatAnalysisTierQueue(http.total, http.high, http.middle, http.low)} · dl ${stats.httpDownloadActive}/${w}`; + const cpuLine = `cpu ${formatAnalysisTierQueue(cpu.total, cpu.high, cpu.middle, cpu.low)} · run ${stats.cpuDecodeActive}/${w}`; + return [httpLine, cpuLine]; +} diff --git a/src/utils/perf/perfFlags.ts b/src/utils/perf/perfFlags.ts index efec512a..5ef71215 100644 --- a/src/utils/perf/perfFlags.ts +++ b/src/utils/perf/perfFlags.ts @@ -3,6 +3,8 @@ import { useSyncExternalStore } from 'react'; export type PerfProbeFlags = { /** On-screen rAF-based FPS counter (Performance Probe). */ showFpsOverlay: boolean; + /** On-screen analysis throughput + last-track timing (Performance Probe). */ + showAnalysisPerfOverlay: boolean; disableWaveformCanvas: boolean; disablePlayerProgressUi: boolean; disableMarqueeScroll: boolean; @@ -32,6 +34,7 @@ const STORAGE_KEY = 'psysonic_perf_probe_flags_v1'; const DEFAULT_FLAGS: PerfProbeFlags = { showFpsOverlay: false, + showAnalysisPerfOverlay: false, disableWaveformCanvas: false, disablePlayerProgressUi: false, disableMarqueeScroll: false, diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index d9778739..e4eb899e 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -9,14 +9,39 @@ import { usePlayerStore } from '../../store/playerStore'; import { switchActiveServer } from '../server/switchActiveServer'; import { sameQueueTrackId } from './queueIdentity'; import type { Track } from '../../store/playerStoreTypes'; +import { resolveServerIdForIndexKey } from '../server/serverLookup'; +import { resolveIndexKey, serverIndexKeyFromUrl } from '../server/serverIndexKey'; /** Server that owns the current queue / stream URLs (may differ from the browsed server). */ export function getPlaybackServerId(): string { const { queueServerId, queue } = usePlayerStore.getState(); - if ((queue?.length ?? 0) > 0 && queueServerId) return queueServerId; + if ((queue?.length ?? 0) > 0 && queueServerId) { + return resolveServerIdForIndexKey(queueServerId); + } return useAuthStore.getState().activeServerId ?? ''; } +export function getPlaybackIndexKey(): string { + const { queueServerId, queue } = usePlayerStore.getState(); + if ((queue?.length ?? 0) > 0 && queueServerId) { + return resolveIndexKey(queueServerId); + } + const activeId = useAuthStore.getState().activeServerId ?? ''; + if (!activeId) return ''; + const server = useAuthStore.getState().servers.find(s => s.id === activeId); + return server ? serverIndexKeyFromUrl(server.url) || activeId : activeId; +} + +/** + * Canonical cache/storage key for playback-owned artifacts (offline/hot-cache). + * Falls back to legacy UUID when an indexKey cannot be resolved yet. + */ +export function getPlaybackCacheServerKey(): string { + const indexKey = getPlaybackIndexKey(); + if (indexKey) return indexKey; + return getPlaybackServerId(); +} + export function bindQueueServerForPlayback(): void { const sid = useAuthStore.getState().activeServerId; if (!sid) return; @@ -31,7 +56,8 @@ export function playbackServerDiffersFromActive(): boolean { const { queueServerId, queue } = usePlayerStore.getState(); if ((queue?.length ?? 0) === 0 || !queueServerId) return false; const activeSid = useAuthStore.getState().activeServerId; - return !!activeSid && queueServerId !== activeSid; + const resolvedQueue = resolveServerIdForIndexKey(queueServerId); + return !!activeSid && resolvedQueue !== activeSid; } /** @@ -44,7 +70,7 @@ export function shouldHandoffQueueToActiveServer(): boolean { const { queue, queueServerId } = usePlayerStore.getState(); if ((queue?.length ?? 0) === 0) return false; if (!queueServerId) return true; - return queueServerId !== activeSid; + return resolveServerIdForIndexKey(queueServerId) !== activeSid; } /** diff --git a/src/utils/playback/resolvePlaybackUrl.ts b/src/utils/playback/resolvePlaybackUrl.ts index 826115bf..a712ba0e 100644 --- a/src/utils/playback/resolvePlaybackUrl.ts +++ b/src/utils/playback/resolvePlaybackUrl.ts @@ -1,7 +1,7 @@ import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl'; import { useOfflineStore } from '../../store/offlineStore'; import { useHotCacheStore } from '../../store/hotCacheStore'; -import { getPlaybackServerId } from './playbackServer'; +import { getPlaybackCacheServerKey, getPlaybackServerId } from './playbackServer'; /** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */ export type PlaybackSourceKind = 'offline' | 'hot' | 'stream'; @@ -41,8 +41,15 @@ export function getPlaybackSourceKind( serverId: string, enginePreloadedTrackId: string | null = null, ): PlaybackSourceKind { - if (useOfflineStore.getState().getLocalUrl(trackId, serverId)) return 'offline'; - if (useHotCacheStore.getState().getLocalUrl(trackId, serverId)) return 'hot'; + const legacySid = getPlaybackServerId(); + const offline = + useOfflineStore.getState().getLocalUrl(trackId, serverId) + || (legacySid && legacySid !== serverId ? useOfflineStore.getState().getLocalUrl(trackId, legacySid) : null); + if (offline) return 'offline'; + const hot = + useHotCacheStore.getState().getLocalUrl(trackId, serverId) + || (legacySid && legacySid !== serverId ? useHotCacheStore.getState().getLocalUrl(trackId, legacySid) : null); + if (hot) return 'hot'; const resolved = resolvePlaybackUrl(trackId, serverId); if ( !resolved.startsWith('psysonic-local://') @@ -56,10 +63,15 @@ export function getPlaybackSourceKind( /** Offline library → hot playback cache → HTTP stream. */ export function resolvePlaybackUrl(trackId: string, serverId?: string): string { - const sid = serverId && serverId.length > 0 ? serverId : getPlaybackServerId(); - const offline = useOfflineStore.getState().getLocalUrl(trackId, sid); + const sid = serverId && serverId.length > 0 ? serverId : getPlaybackCacheServerKey(); + const legacySid = getPlaybackServerId(); + const offline = + useOfflineStore.getState().getLocalUrl(trackId, sid) + || (legacySid && legacySid !== sid ? useOfflineStore.getState().getLocalUrl(trackId, legacySid) : null); if (offline) return offline; - const hot = useHotCacheStore.getState().getLocalUrl(trackId, sid); + const hot = + useHotCacheStore.getState().getLocalUrl(trackId, sid) + || (legacySid && legacySid !== sid ? useHotCacheStore.getState().getLocalUrl(trackId, legacySid) : null); if (hot) return hot; return buildStreamUrlForServer(sid, trackId); } diff --git a/src/utils/server/rewriteFrontendStoreKeys.ts b/src/utils/server/rewriteFrontendStoreKeys.ts new file mode 100644 index 00000000..ff35344f --- /dev/null +++ b/src/utils/server/rewriteFrontendStoreKeys.ts @@ -0,0 +1,111 @@ +import type { ServerProfile } from '../../store/authStoreTypes'; +import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; +import { useHotCacheStore } from '../../store/hotCacheStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import { useOfflineStore } from '../../store/offlineStore'; +import { serverIndexKeyFromUrl } from './serverIndexKey'; + +type Mapping = { legacyId: string; indexKey: string }; + +function buildMappings(servers: ServerProfile[]): Mapping[] { + return servers + .map(server => ({ + legacyId: server.id.trim(), + indexKey: serverIndexKeyFromUrl(server.url).trim(), + })) + .filter(mapping => mapping.legacyId.length > 0 && mapping.indexKey.length > 0); +} + +function rewriteOfflineStoreKeys(mappings: Mapping[]): void { + const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey])); + useOfflineStore.setState((state) => { + const tracks = { ...state.tracks }; + for (const [key, meta] of Object.entries(state.tracks)) { + const i = key.indexOf(':'); + if (i <= 0) continue; + const legacyId = key.slice(0, i); + const trackId = key.slice(i + 1); + const indexKey = map.get(legacyId); + if (!indexKey) continue; + const nextKey = `${indexKey}:${trackId}`; + if (!tracks[nextKey]) { + tracks[nextKey] = { ...meta, serverId: indexKey }; + } + delete tracks[key]; + } + + const albums = { ...state.albums }; + for (const [key, meta] of Object.entries(state.albums)) { + const i = key.indexOf(':'); + if (i <= 0) continue; + const legacyId = key.slice(0, i); + const albumId = key.slice(i + 1); + const indexKey = map.get(legacyId); + if (!indexKey) continue; + const nextKey = `${indexKey}:${albumId}`; + if (!albums[nextKey]) { + albums[nextKey] = { ...meta, serverId: indexKey }; + } + delete albums[key]; + } + return { tracks, albums }; + }); +} + +function rewriteHotCacheStoreKeys(mappings: Mapping[]): void { + const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey])); + useHotCacheStore.setState((state) => { + const entries = { ...state.entries }; + for (const [key, entry] of Object.entries(state.entries)) { + const i = key.indexOf(':'); + if (i <= 0) continue; + const legacyId = key.slice(0, i); + const trackId = key.slice(i + 1); + const indexKey = map.get(legacyId); + if (!indexKey) continue; + const nextKey = `${indexKey}:${trackId}`; + if (!entries[nextKey]) { + entries[nextKey] = entry; + } + delete entries[key]; + } + return { entries }; + }); +} + +function rewriteAnalysisStrategyStoreKeys(mappings: Mapping[]): void { + const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey])); + useAnalysisStrategyStore.setState((state) => { + const strategyByServer = { ...state.strategyByServer }; + for (const [key, value] of Object.entries(state.strategyByServer)) { + const indexKey = map.get(key); + if (!indexKey || value === undefined) continue; + if (strategyByServer[indexKey] === undefined) { + strategyByServer[indexKey] = value; + } + delete strategyByServer[key]; + } + + const advancedParallelismByServer = { ...state.advancedParallelismByServer }; + for (const [key, value] of Object.entries(state.advancedParallelismByServer)) { + const indexKey = map.get(key); + if (!indexKey || value === undefined) continue; + if (advancedParallelismByServer[indexKey] === undefined) { + advancedParallelismByServer[indexKey] = value; + } + delete advancedParallelismByServer[key]; + } + return { strategyByServer, advancedParallelismByServer }; + }); +} + +export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promise { + const mappings = buildMappings(servers); + if (mappings.length === 0) return; + rewriteOfflineStoreKeys(mappings); + rewriteHotCacheStoreKeys(mappings); + rewriteAnalysisStrategyStoreKeys(mappings); + // Keep migration explicit: Zustand persist writes the current state snapshot. + useAnalysisStrategyStore.getState().migrateServerOverrides(servers); + useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled })); +} diff --git a/src/utils/server/serverDisplayName.ts b/src/utils/server/serverDisplayName.ts index 8009236c..ee4d5f14 100644 --- a/src/utils/server/serverDisplayName.ts +++ b/src/utils/server/serverDisplayName.ts @@ -1,7 +1,7 @@ import type { ServerProfile } from '../../store/authStoreTypes'; /** Host (+ port) from a server base URL, e.g. `https://music.one.com/foo` → `music.one.com`. */ -export function shortHostFromServerUrl(urlRaw: string): string { - const t = urlRaw.trim(); +export function shortHostFromServerUrl(urlRaw?: string | null): string { + const t = typeof urlRaw === 'string' ? urlRaw.trim() : ''; if (!t) return ''; try { const u = new URL(t.includes('://') ? t : `https://${t}`); @@ -21,7 +21,8 @@ export function shortHostFromServerUrl(urlRaw: string): string { */ export function serverListDisplayLabel(server: ServerProfile, all: ServerProfile[]): string { const nameTrim = (server.name || '').trim(); - const shortHost = shortHostFromServerUrl(server.url); + const safeUrl = typeof server.url === 'string' ? server.url : ''; + const shortHost = shortHostFromServerUrl(safeUrl); const key = nameTrim || shortHost; const collisions = all.filter(s => { const nt = (s.name || '').trim(); @@ -29,7 +30,7 @@ export function serverListDisplayLabel(server: ServerProfile, all: ServerProfile return (nt || sh) === key; }); if (collisions.length < 2) { - return nameTrim || shortHost || server.url.trim(); + return nameTrim || shortHost || safeUrl.trim(); } return `${server.username}@${shortHost}`; } diff --git a/src/utils/server/serverIndexKey.ts b/src/utils/server/serverIndexKey.ts new file mode 100644 index 00000000..05655423 --- /dev/null +++ b/src/utils/server/serverIndexKey.ts @@ -0,0 +1,19 @@ +import type { ServerProfile } from '../../store/authStoreTypes'; +import { useAuthStore } from '../../store/authStore'; +import { serverProfileBaseUrl } from './serverBaseUrl'; + +/** Stable index key derived from a server URL (host + optional path, no scheme). */ +export function serverIndexKeyFromUrl(urlRaw: string): string { + const base = serverProfileBaseUrl({ url: urlRaw }); + return base.replace(/^https?:\/\//, ''); +} + +export function serverIndexKeyForProfile(server: Pick): string { + return serverIndexKeyFromUrl(server.url); +} + +export function resolveIndexKey(serverIdOrKey: string): string { + const server = useAuthStore.getState().servers.find(s => s.id === serverIdOrKey); + if (!server) return serverIdOrKey; + return serverIndexKeyFromUrl(server.url) || serverIdOrKey; +} diff --git a/src/utils/server/serverIndexMigration.ts b/src/utils/server/serverIndexMigration.ts new file mode 100644 index 00000000..8db7debf --- /dev/null +++ b/src/utils/server/serverIndexMigration.ts @@ -0,0 +1,10 @@ +import { useAuthStore } from '../../store/authStore'; +import { rewriteFrontendStoreKeys } from './rewriteFrontendStoreKeys'; + +/** + * Legacy compatibility shim. The migration lifecycle now runs through + * `useMigrationOrchestrator` + blocking gate. + */ +export async function migrateServerIndexKeysIfNeeded(): Promise { + await rewriteFrontendStoreKeys(useAuthStore.getState().servers); +} diff --git a/src/utils/server/serverLookup.ts b/src/utils/server/serverLookup.ts new file mode 100644 index 00000000..22c4da67 --- /dev/null +++ b/src/utils/server/serverLookup.ts @@ -0,0 +1,22 @@ +import { useAuthStore } from '../../store/authStore'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { serverIndexKeyForProfile, serverIndexKeyFromUrl } from './serverIndexKey'; + +export function findServerByIdOrIndexKey(serverIdOrKey: string): ServerProfile | undefined { + const servers = useAuthStore.getState().servers; + const direct = servers.find(s => s.id === serverIdOrKey); + if (direct) return direct; + return servers.find(s => serverIndexKeyForProfile(s) === serverIdOrKey); +} + +export function resolveServerIdForIndexKey(serverIdOrKey: string): string { + const { servers, activeServerId } = useAuthStore.getState(); + const direct = servers.find(s => s.id === serverIdOrKey); + if (direct) return direct.id; + const active = servers.find( + s => s.id === activeServerId && serverIndexKeyFromUrl(s.url) === serverIdOrKey, + ); + if (active) return active.id; + const fallback = servers.find(s => serverIndexKeyFromUrl(s.url) === serverIdOrKey); + return fallback?.id ?? serverIdOrKey; +} diff --git a/src/utils/share/enqueueShareSearchPayload.ts b/src/utils/share/enqueueShareSearchPayload.ts index 9b1c8eca..ec91111e 100644 --- a/src/utils/share/enqueueShareSearchPayload.ts +++ b/src/utils/share/enqueueShareSearchPayload.ts @@ -14,6 +14,7 @@ import { songToTrack } from '../playback/songToTrack'; import type { Track } from '../../store/playerStoreTypes'; import { orbitBulkGuard } from '../orbitBulkGuard'; import { findServerIdForShareUrl } from './shareLink'; +import { serverIndexKeyFromUrl } from '../server/serverIndexKey'; import type { AlbumShareSearchPayload, ArtistShareSearchPayload, @@ -61,7 +62,10 @@ function lookupShareServer(shareSrv: string): ShareServerLookupResult { } const serverId = findServerIdForShareUrl(servers, shareSrv); - const server = serverId ? servers.find(s => s.id === serverId) : undefined; + const server = serverId + ? servers.find(s => s.id === serverId) + ?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId) + : undefined; if (!serverId || !server) { return { type: 'no-matching-server', url: shareSrv }; } diff --git a/src/utils/share/shareServerOriginLabel.ts b/src/utils/share/shareServerOriginLabel.ts index a927f819..ea80b239 100644 --- a/src/utils/share/shareServerOriginLabel.ts +++ b/src/utils/share/shareServerOriginLabel.ts @@ -1,5 +1,6 @@ import type { ServerProfile } from '../../store/authStoreTypes'; import { serverListDisplayLabel } from '../server/serverDisplayName'; +import { serverIndexKeyFromUrl } from '../server/serverIndexKey'; import { findServerIdForShareUrl } from './shareLink'; import type { ShareSearchMatch } from './shareSearch'; @@ -18,7 +19,8 @@ export function shareServerOriginLabel( const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv); if (!shareServerId || shareServerId === activeServerId) return null; - const server = servers.find(s => s.id === shareServerId); + const server = servers.find(s => s.id === shareServerId) + ?? servers.find(s => serverIndexKeyFromUrl(s.url) === shareServerId); if (!server) return null; return serverListDisplayLabel(server, servers); @@ -37,6 +39,10 @@ export function shareQueueServerContext( const label = shareServerOriginLabel(match, servers, activeServerId); const serverId = findServerIdForShareUrl(servers, shareSrv); const coverServer = - serverId && serverId !== activeServerId ? servers.find(s => s.id === serverId) ?? null : null; + serverId && serverId !== activeServerId + ? servers.find(s => s.id === serverId) + ?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId) + ?? null + : null; return { label, coverServer }; }