From 11974e1438dae2def7bc2430ce34ab8d1f22823c Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 24 May 2026 21:11:04 +0300 Subject: [PATCH] feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864) * feat(analysis): align index settings and per-server strategies Rebuild the local index UX to live under Servers with per-server analytics strategies, and scope analysis queue hints/pruning by playback server so priorities stay isolated across profiles. * feat(analysis): add progress tracking and server analysis deletion functionality Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features. * feat(server): implement server index key migration and enhance server ID resolution Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations. * refactor(library): simplify server ID handling in sync progress and idle subscriptions Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality. * refactor(analytics): rename advanced strategy to aggressive and update descriptions Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code. * fix(audio): update server ID handling in audio progress functions Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure. * refactor(analysis): update server ID handling and drop legacy keys Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability. * refactor(migration): switch to strategy C dual-db flow Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior. * fix(migration): harden runtime db switch and startup gate Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows. * feat(migration): enhance migration reporting with skipped server rows tracking Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status. * fix(migration): avoid startup blocking modal on no-op runs Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed. * fix(migration): enforce startup precheck and purge unknown rows Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB. * fix(migration): block UI during done-flag precheck Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed. * fix(migration): hide precheck modal when no migration is needed Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users. * fix(migration): cleanup legacy db files after path migration Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration. * docs(changelog): add PR #864 release notes and contributor credit Document the full index-key rebuild scope for 1.47.0 and add the corresponding settings credit entry for PR #864. * test(analysis): raise hot-path coverage for analysis cache Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths. * fix(migration): make rebind pass resilient to foreign key ordering Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates. * feat(backup): add dual-database backup flow and blocking UX Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs. * docs(changelog): add PR #864 backup notes and contributor credit Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh. * docs(changelog): sort 1.47.0 entries from old to new Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block. * fix(playback): align offline/hot cache lookup with indexKey scope Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope. --- CHANGELOG.md | 94 +- src-tauri/Cargo.lock | 147 +- src-tauri/Cargo.toml | 1 + src-tauri/crates/psysonic-analysis/Cargo.toml | 3 + .../src/analysis_cache/compute.rs | 261 +++- .../src/analysis_cache/mod.rs | 4 +- .../src/analysis_cache/store.rs | 869 +++++++---- .../psysonic-analysis/src/analysis_perf.rs | 42 + .../psysonic-analysis/src/analysis_runtime.rs | 1287 +++++++++++++---- .../crates/psysonic-analysis/src/commands.rs | 232 ++- src-tauri/crates/psysonic-analysis/src/lib.rs | 1 + .../psysonic-analysis/src/track_enrichment.rs | 8 +- .../psysonic-audio/src/analysis_dispatch.rs | 111 +- .../crates/psysonic-audio/src/commands.rs | 6 +- .../psysonic-audio/src/preload_commands.rs | 15 +- .../psysonic-audio/src/stream/ranged_http.rs | 10 +- .../psysonic-audio/src/stream/track_stream.rs | 4 +- src-tauri/crates/psysonic-core/src/ports.rs | 25 + .../psysonic-library/src/analysis_backfill.rs | 165 +++ .../crates/psysonic-library/src/commands.rs | 94 +- src-tauri/crates/psysonic-library/src/lib.rs | 1 + .../psysonic-library/src/live_search.rs | 5 +- .../psysonic-library/src/repos/track.rs | 105 ++ .../crates/psysonic-library/src/runtime.rs | 77 + .../crates/psysonic-library/src/store.rs | 203 ++- .../crates/psysonic-syncfs/src/cache/hot.rs | 34 +- .../psysonic-syncfs/src/cache/offline.rs | 10 +- src-tauri/src/lib.rs | 33 + src-tauri/src/lib_commands/app_api/backup.rs | 447 ++++++ src-tauri/src/lib_commands/app_api/core.rs | 4 + .../src/lib_commands/app_api/migration.rs | 756 ++++++++++ src-tauri/src/lib_commands/app_api/mod.rs | 6 + src/api/analysis.ts | 121 ++ src/api/library.ts | 154 +- src/api/migration.ts | 56 + src/api/subsonicClient.ts | 6 +- src/api/subsonicStreamUrl.ts | 3 +- src/app/AppShell.tsx | 1 + src/app/BlockingMigrationGate.tsx | 85 ++ src/app/MainApp.tsx | 67 +- src/components/FpsOverlay.tsx | 97 +- src/components/LiveSearch.tsx | 6 +- src/components/Sidebar.tsx | 3 +- .../settings/AnalyticsStrategySection.tsx | 371 +++++ src/components/settings/BackupSection.tsx | 173 ++- .../settings/LibraryIndexSection.tsx | 474 ------ .../settings/LibraryIndexServerRow.tsx | 155 -- src/components/settings/LibraryTab.tsx | 7 +- .../settings/ServerLibraryIndexControls.tsx | 124 ++ src/components/settings/ServersTab.tsx | 22 +- src/components/settings/settingsTabs.ts | 3 +- .../sidebar/SidebarPerfProbeModal.tsx | 31 +- .../statistics/PlayerStatisticsPanel.tsx | 2 - .../PlayerStatsIndexRequiredNotice.tsx | 2 +- .../PlayerStatsPartialIndexNotice.tsx | 40 - src/config/settingsCredits.ts | 2 + src/hooks/useAnalysisPerfListener.ts | 40 + src/hooks/useLibraryAnalysisBackfill.ts | 135 ++ src/hooks/useLibraryIndexSync.ts | 243 ++++ src/hooks/useMigrationOrchestrator.test.ts | 148 ++ src/hooks/useMigrationOrchestrator.ts | 134 ++ src/hooks/usePlayerStatsRecordingEnabled.ts | 4 +- src/hooks/useShareSearch.ts | 5 +- src/hooks/useSidebarPerfProbe.ts | 43 +- src/hotCachePrefetch.ts | 12 +- src/hotCachePrefetch/analysisPrune.ts | 26 +- src/locales/en/settings.ts | 50 + src/locales/en/statistics.ts | 7 +- src/locales/ru/settings.ts | 54 + src/locales/ru/statistics.ts | 8 +- src/store/analysisStrategyStore.test.ts | 60 + src/store/analysisStrategyStore.ts | 147 ++ src/store/audioEventHandlers.ts | 19 +- src/store/libraryIndexStore.ts | 78 +- src/store/loudnessBackfillWindow.ts | 39 + src/store/loudnessRefresh.test.ts | 1 + src/store/loudnessRefresh.ts | 14 +- src/store/loudnessReseed.ts | 4 +- src/store/migrationStore.ts | 30 + src/store/playListenSession.test.ts | 1 - src/store/playTrackAction.ts | 17 +- src/store/queueUndoAudioRestore.test.ts | 1 + src/store/queueUndoAudioRestore.ts | 16 +- src/store/resumeAction.ts | 14 +- src/store/waveformRefresh.ts | 4 +- .../components/orbit-session-top-strip.css | 15 + src/utils/export/backup.ts | 135 +- src/utils/library/advancedSearchLocal.test.ts | 6 +- src/utils/library/analysisStrategy.ts | 17 + .../libraryAnalysisBackfillPolicy.test.ts | 34 + .../library/libraryAnalysisBackfillPolicy.ts | 39 + src/utils/library/librarySession.ts | 31 +- src/utils/library/librarySyncQueue.ts | 4 + src/utils/library/patchOnUse.test.ts | 4 +- src/utils/library/queueRestore.test.ts | 2 +- src/utils/library/queueTrackResolver.test.ts | 2 +- src/utils/offline/offlineLibraryHelpers.ts | 9 +- src/utils/perf/analysisPerfStore.test.ts | 44 + src/utils/perf/analysisPerfStore.ts | 89 ++ .../perf/formatAnalysisQueueStats.test.ts | 65 + src/utils/perf/formatAnalysisQueueStats.ts | 49 + src/utils/perf/perfFlags.ts | 3 + src/utils/playback/playbackServer.ts | 32 +- src/utils/playback/resolvePlaybackUrl.ts | 24 +- src/utils/server/rewriteFrontendStoreKeys.ts | 111 ++ src/utils/server/serverDisplayName.ts | 9 +- src/utils/server/serverIndexKey.ts | 19 + src/utils/server/serverIndexMigration.ts | 10 + src/utils/server/serverLookup.ts | 22 + src/utils/share/enqueueShareSearchPayload.ts | 6 +- src/utils/share/shareServerOriginLabel.ts | 10 +- 111 files changed, 7537 insertions(+), 1673 deletions(-) create mode 100644 src-tauri/crates/psysonic-analysis/src/analysis_perf.rs create mode 100644 src-tauri/crates/psysonic-library/src/analysis_backfill.rs create mode 100644 src-tauri/src/lib_commands/app_api/backup.rs create mode 100644 src-tauri/src/lib_commands/app_api/migration.rs create mode 100644 src/api/analysis.ts create mode 100644 src/api/migration.ts create mode 100644 src/app/BlockingMigrationGate.tsx create mode 100644 src/components/settings/AnalyticsStrategySection.tsx delete mode 100644 src/components/settings/LibraryIndexSection.tsx delete mode 100644 src/components/settings/LibraryIndexServerRow.tsx create mode 100644 src/components/settings/ServerLibraryIndexControls.tsx delete mode 100644 src/components/statistics/PlayerStatsPartialIndexNotice.tsx create mode 100644 src/hooks/useAnalysisPerfListener.ts create mode 100644 src/hooks/useLibraryAnalysisBackfill.ts create mode 100644 src/hooks/useLibraryIndexSync.ts create mode 100644 src/hooks/useMigrationOrchestrator.test.ts create mode 100644 src/hooks/useMigrationOrchestrator.ts create mode 100644 src/store/analysisStrategyStore.test.ts create mode 100644 src/store/analysisStrategyStore.ts create mode 100644 src/store/migrationStore.ts create mode 100644 src/utils/library/analysisStrategy.ts create mode 100644 src/utils/library/libraryAnalysisBackfillPolicy.test.ts create mode 100644 src/utils/library/libraryAnalysisBackfillPolicy.ts create mode 100644 src/utils/perf/analysisPerfStore.test.ts create mode 100644 src/utils/perf/analysisPerfStore.ts create mode 100644 src/utils/perf/formatAnalysisQueueStats.test.ts create mode 100644 src/utils/perf/formatAnalysisQueueStats.ts create mode 100644 src/utils/server/rewriteFrontendStoreKeys.ts create mode 100644 src/utils/server/serverIndexKey.ts create mode 100644 src/utils/server/serverIndexMigration.ts create mode 100644 src/utils/server/serverLookup.ts 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 }; }