From 2d3c723a6eb831de558a18717d6d70e5ce1b4ea4 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:43:03 +0300 Subject: [PATCH] feat(offline): unify local playback, offline library, and favorites sync (#1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(local-playback): LP-1 media layout and download_track_local Add library-index-backed path builder in psysonic-core and a unified Tauri download command that writes under media/{cache|library}/ with layout fingerprints; legacy hot/offline commands unchanged for now. * feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers Add unified Zustand index with legacy offline/hot-cache import, media_layout TS mirror, and Rust commands for tier size/purge/delete/promote. * feat(local-playback): LP-3 wire prefetch and playback to unified index Route downloads through download_track_local, delegate hot/offline shims to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite. * feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir Offline Library loads pinned groups via library index; sync-idle invalidates stale paths; Settings uses a single mediaDir with cache/library tier sizes. * feat(local-playback): migrate legacy offline files to media/library layout Move flat psysonic-offline downloads into nested media/library paths using library index metadata, with retry on sync-idle when tracks are not yet indexed. * feat(local-playback): simplify offline disk migration and restore Offline Library UI Scan psysonic-offline on disk and relocate by library track id; restore pinSource and cover art for migrated pins; add find_live_by_id for segment/key resolution. * feat(local-playback): disk-first offline reconcile and fast library tier discovery Reconcile library-tier index against on-disk files using candidate track IDs instead of scanning the full catalog. Refresh Offline Library from disk on open and focus so deleted folders drop out of the UI. Add Rust discover/prune helpers and wire album/server reconcile through the unified path. * fix(offline): resolve local playback URLs across server index-key variants Offline Library play failed when library-tier files were indexed under a host key while playback looked up only the active profile UUID. Use findLocalPlaybackEntry for URL resolution, pin queueServerId to the card server, and build play queues from tracks that still have on-disk bytes. * fix(offline): playlist cards, playback from Offline Library, and local URL routing Show playlists with name and quad/custom cover instead of the first track's album artist. Build play queues with library-batch fallback and offline-only server switch. Prefer library-tier URLs in playTrack; add playback-unavailable toast and missing trackToSong import. * fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI Sweep media/cache after eviction (orphan files, stale index, empty folders). Settings: split media folder from cover cache; in-browser image cache lives under Cover art cache with aligned columns; clear only IndexedDB images. * feat(offline): show library disk usage in Offline Library header Query media/library tier size on reconcile and display it in a right-aligned stat block beside the page title and album count. * fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap Reconcile ephemeral cache without deleting files from other app instances; evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb. Remove the hidden maxCacheMb gate and offline-full banner on album pages. * feat(offline): play-all cache card and stable Offline Library grid rows Add a shuffle-and-play card for all on-disk library pins plus hot-cache tracks when buffering is enabled. Fix virtual row height for offline cards and reserve the year line so grid rows no longer overlap. * feat(offline): queue-cache grid card limited to media/cache Replace the full-width play-all banner with a playlist-style grid tile. Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled, not offline library pins. * chore(licenses): regenerate bundled OSS list for 1.48.0-dev Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted licenses so generate-licenses.mjs runs; refresh src/data/licenses.json. * feat(favorites): auto-sync starred tracks into separate media/favorites tier Keep manual Offline Library in media/library/ and favorites offline in favorite-auto/index + media/favorites/ so toggling sync cannot purge user-pinned bytes; playback resolves library before favorites. * feat(favorites): compact offline toggle with disk icon and sync semaphore Move control to the page header (disk + switch, tooltips); show red/yellow/green LED when enabled instead of the full-width save-offline card. * fix(favorites): trigger offline sync on star/unstar from anywhere Hook star/unstar API so favorites offline reconcile runs globally (songs, albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync. * fix(cache): skip hot-cache prefetch when favorites or library bytes exist Treat favorite-auto tier like offline library for prefetch, stream promote, and same-track replay so synced favorites are not duplicated in media/cache. * fix(favorites): reconcile offline files on merged track union only Dedupe artist/album/song stars into one target set per track id; drop eager unstar deletes so overlapping favorites do not remove bytes still needed. * feat(offline): add Favorites card to Offline Library Mirror queue-cache card for favorite-auto tier with play, enqueue, and navigation to Favorites on card click. * feat(offline): show library+favorites disk total with icon breakdown Sum media/library and media/favorites in the On disk widget and open an icon popover on hover with per-tier sizes for screen readers and sighted users. * feat(favorites): enable offline Favorites tab when auto-save is on Keep Favorites in the sidebar when disconnected, land on /favorites without manual pins, and load starred rows from the local library index. * feat(favorites): cross-server offline browse with per-server covers When auto-save is on, Favorites merges starred items from every indexed server and syncs each server independently. Detail links carry ?server= for offline album/artist pages; cover art resolves disk cache by entity serverId instead of the active server only. * feat(playback): mixed-server queue scope and cross-server favorites sync Per-ref server identity for playback (URL index key in queue refs, profile UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers by playing ref. Remove cross-server enqueue block; remap queueItems on URL remigration. Favorites: star/unstar and favorite-auto sync target the owning serverId (not only active); queueSongStar passes server through pending sync. * fix(offline): suppress Subsonic calls during favorites and local playback Add reachability guards so offline favorites browse, album detail, queue sync, scrobble, and Now Playing metadata skip network when the server is down or the track plays from psysonic-local. Load starred albums/tracks from the library index only (not the full artist table), refresh favorites from index first, and pass server scope in favorites navigation. * fix(queue): export share and playlist save for active server only Mixed-server queues now filter queue refs by the browsed server profile before copying a share link or saving/updating a playlist from the toolbar. * fix(offline): complete album pins and resume interrupted downloads Prefer full getAlbum track lists when online so partial library index does not truncate offline pins; refresh songs before pin from album detail. Resume incomplete persisted pins after reconcile and reconnect, cancel in-flight work on delete, and chunk library batch fetches past 100 refs. * fix(offline): pin queue, queued UI, and re-pin after remove Serialize album and playlist offline pins so parallel enqueue no longer drops in-flight work. Show an explicit queued state on album and playlist actions with dequeue on repeat click, sidebar tooltips for long labels, and clear stale cancel flags so Make available offline works after remove. * fix(offline): remove Offline Library cards without full page reload Optimistically drop the deleted card from local grid state, show the loading spinner only on first visit, and ignore stale disk refreshes so pin updates no longer flash the whole library view. * fix(offline): artist discography pin state and queue handling Detect cached/queued/downloading from persisted album pins instead of ephemeral bulkProgress, skip already offline or in-flight albums when enqueueing discography, and show the correct hero button after revisit. * fix(local-playback): address LP-1 review handoff (B1, M1–M7) Harden media path sanitization and tier containment, align Rust/TS layout fingerprints, serialize per-track downloads, and fix favorites re-enable, multi-server debounce, prev-track promote key, now-playing reachability, and ephemeral prefetch soft-skip for unindexed tracks. * fix(offline): cancel in-flight favorites downloads on unstar Abort Rust streams with the real favorites downloadId when sync is rescheduled or disabled, and drop completed bytes that no longer belong in the starred set so unstar does not leave orphan files on disk. * fix(build): resolve TypeScript errors blocking prod nix build Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks so `tsc && vite build` passes under the flake beforeBuildCommand. * fix(rust): satisfy clippy too_many_arguments for CI Bundle offline-library analysis and local path/migration helpers into parameter structs so `cargo clippy -D warnings` passes on the branch. * fix(settings): show correct hot-cache track count in Buffering section Count ephemeral localPlayback rows instead of prefix-matching index keys, which always missed host:port server segments and showed zero tracks. * fix(media-layout): align truncation threshold on code points (M1) Rust sanitize_and_truncate_segment now uses char count like TS so long non-ASCII metadata does not diverge layout fingerprints; add Cyrillic parity tests and clarify ephemeral cold-miss doc on download_track_local. * fix(test): use numeric cachedAt in hotCacheStore count test Align test fixture with LocalPlaybackEntry type so tsc passes in CI. * docs: add CHANGELOG and credits for offline experience PR #1008 * feat(offline): auto-sync manually cached playlists when track list changes Re-download new tracks and prune removed ones for playlist pins only, triggered from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect. * docs: note cached-playlist sync in CHANGELOG and credits for PR #1008 * fix(offline): exclude smart playlists from manual offline cache and sync Hide cache-offline for psy-smart-* playlists, block download/sync paths, and document the distinction in CHANGELOG. * feat(offline): auto-sync cached albums and artist discographies Generalize pinned playlist reconcile into pinnedOfflineSync so manually pinned albums and artist discographies re-download added tracks and prune removed ones on reopen, reconnect, and catalog changes. * feat(offline): split pinned sync triggers by pin kind Album and artist pins reconcile after library index sync and reconnect; regular playlists reconcile hourly and on in-app playlist edits only. Remove reconcile-on-open for album, artist, and playlist detail views. * fix(offline): address PR #1008 review (N1, tests, pin queue) Scope playlist reconcile to the owning server via getPlaylistForServer. Add artist discography and mixed-server playlist tests; dedupe pending sync jobs; skip pinTasks overwrite during active downloads. --- CHANGELOG.md | 14 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 +- src-tauri/about.toml | 3 +- src-tauri/crates/psysonic-analysis/Cargo.toml | 1 + .../psysonic-analysis/src/analysis_runtime.rs | 139 ++ .../src/track_analysis_plan.rs | 92 +- src-tauri/crates/psysonic-audio/Cargo.toml | 1 + src-tauri/crates/psysonic-core/Cargo.toml | 1 + .../psysonic-core/src/cover_cache_layout.rs | 36 +- src-tauri/crates/psysonic-core/src/lib.rs | 1 + .../crates/psysonic-core/src/media_layout.rs | 364 +++++ .../crates/psysonic-integration/Cargo.toml | 1 + src-tauri/crates/psysonic-library/Cargo.toml | 1 + .../crates/psysonic-library/src/commands.rs | 34 + .../psysonic-library/src/repos/track.rs | 39 + src-tauri/crates/psysonic-syncfs/Cargo.toml | 2 + .../psysonic-syncfs/src/cache/fs_utils.rs | 102 +- .../crates/psysonic-syncfs/src/cache/local.rs | 1422 +++++++++++++++++ .../crates/psysonic-syncfs/src/cache/mod.rs | 1 + src-tauri/src/lib.rs | 14 + src/api/library.ts | 24 + src/api/subsonic.async.test.ts | 5 + src/api/subsonicLibrary.ts | 15 + src/api/subsonicPlaylists.ts | 22 +- src/api/subsonicScrobble.test.ts | 3 + src/api/subsonicScrobble.ts | 2 + src/api/subsonicStarRating.ts | 64 +- src/api/subsonicTypes.ts | 6 + src/app/AppShell.tsx | 19 +- src/app/MainApp.tsx | 28 + src/components/AlbumCard.tsx | 32 +- src/components/AlbumHeader.tsx | 20 +- src/components/AlbumRow.tsx | 2 +- src/components/ArtistCardLocal.tsx | 13 +- src/components/ArtistRow.tsx | 2 +- src/components/MobileMoreOverlay.tsx | 12 + src/components/MobilePlayerView.tsx | 2 +- src/components/OfflineLibraryDiskStat.tsx | 135 ++ src/components/PlayerBar.tsx | 2 +- src/components/QueuePanel.tsx | 12 +- src/components/Sidebar.tsx | 34 +- .../artistDetail/ArtistDetailHero.tsx | 76 +- .../contextMenu/AlbumContextItems.tsx | 1 + .../contextMenu/ArtistContextItems.tsx | 6 +- .../contextMenu/QueueItemContextItems.tsx | 2 +- .../contextMenu/SongContextItems.tsx | 4 +- src/components/favorites/FavoriteSongRow.tsx | 8 +- .../favorites/FavoritesOfflineHeader.tsx | 74 + .../favorites/FavoritesSongsTracklist.tsx | 11 +- .../favorites/TopFavoriteArtists.tsx | 9 +- .../FullscreenPlayerStatic.tsx | 2 +- src/components/playlist/PlaylistHero.tsx | 35 +- .../settings/CoverCacheStrategySection.tsx | 163 +- src/components/settings/StorageTab.tsx | 226 +-- src/components/settings/settingsTabs.ts | 4 +- src/components/sidebar/SidebarActiveJobs.tsx | 25 +- src/components/sidebar/SidebarNavBody.tsx | 6 +- src/config/settingsCredits.ts | 1 + src/config/shortcutActionRegistry.ts | 6 +- src/cover/index.ts | 1 + src/cover/ref.ts | 18 +- src/cover/serverScope.test.ts | 34 + src/cover/serverScope.ts | 18 + src/cover/useLibraryCoverRef.ts | 23 +- src/cover/usePlaybackCoverArt.ts | 6 +- src/data/licenses.json | 2 +- src/hooks/useAlbumDetailData.ts | 110 +- src/hooks/useAlbumOfflineState.test.ts | 52 + src/hooks/useAlbumOfflineState.ts | 54 +- src/hooks/useArtistDetailData.test.ts | 12 +- src/hooks/useArtistDetailData.ts | 62 +- src/hooks/useArtistOfflineState.test.ts | 63 + src/hooks/useArtistOfflineState.ts | 56 + src/hooks/useConnectionStatus.ts | 10 +- src/hooks/useFavoritesData.ts | 83 +- src/hooks/useFavoritesOfflineStatus.ts | 87 + src/hooks/useFavoritesSongFiltering.tsx | 12 +- src/hooks/useNowPlayingFetchers.test.ts | 3 + src/hooks/useNowPlayingFetchers.ts | 28 +- src/hooks/useNowPlayingPrewarm.ts | 3 +- src/hooks/useNowPlayingStarLove.ts | 5 +- src/hooks/useOfflineAutoNav.ts | 32 +- src/hooks/usePlaybackServerId.test.ts | 17 + src/hooks/usePlaybackServerId.ts | 7 +- src/hooks/usePlaylistStarRating.ts | 2 +- src/hooks/useSidebarNewReleasesUnread.ts | 3 +- src/hotCachePrefetch.ts | 82 +- src/localPlaybackInvalidation.ts | 64 + src/locales/de/albumDetail.ts | 2 + src/locales/de/artistDetail.ts | 1 + src/locales/de/connection.ts | 10 + src/locales/de/favorites.ts | 5 + src/locales/de/settings.ts | 13 +- src/locales/en/albumDetail.ts | 2 + src/locales/en/artistDetail.ts | 1 + src/locales/en/connection.ts | 11 + src/locales/en/favorites.ts | 5 + src/locales/en/settings.ts | 16 +- src/locales/en/sidebar.ts | 2 + src/locales/es/albumDetail.ts | 2 + src/locales/es/artistDetail.ts | 1 + src/locales/es/connection.ts | 10 + src/locales/es/favorites.ts | 5 + src/locales/es/settings.ts | 13 +- src/locales/fr/albumDetail.ts | 2 + src/locales/fr/artistDetail.ts | 1 + src/locales/fr/connection.ts | 10 + src/locales/fr/favorites.ts | 5 + src/locales/fr/settings.ts | 13 +- src/locales/nb/albumDetail.ts | 2 + src/locales/nb/artistDetail.ts | 1 + src/locales/nb/connection.ts | 10 + src/locales/nb/favorites.ts | 5 + src/locales/nb/settings.ts | 13 +- src/locales/nl/albumDetail.ts | 2 + src/locales/nl/artistDetail.ts | 1 + src/locales/nl/connection.ts | 10 + src/locales/nl/favorites.ts | 5 + src/locales/nl/settings.ts | 13 +- src/locales/ro/albumDetail.ts | 2 + src/locales/ro/artistDetail.ts | 1 + src/locales/ro/connection.ts | 10 + src/locales/ro/favorites.ts | 5 + src/locales/ro/settings.ts | 13 +- src/locales/ru/albumDetail.ts | 2 + src/locales/ru/artistDetail.ts | 1 + src/locales/ru/connection.ts | 11 + src/locales/ru/favorites.ts | 5 + src/locales/ru/settings.ts | 16 +- src/locales/ru/sidebar.ts | 2 + src/locales/zh/albumDetail.ts | 2 + src/locales/zh/artistDetail.ts | 1 + src/locales/zh/connection.ts | 10 + src/locales/zh/favorites.ts | 5 + src/locales/zh/settings.ts | 13 +- src/pages/AlbumDetail.tsx | 84 +- src/pages/ArtistDetail.tsx | 10 +- src/pages/ComposerDetail.tsx | 6 +- src/pages/Favorites.tsx | 9 +- src/pages/NowPlaying.tsx | 4 +- src/pages/OfflineLibrary.tsx | 630 +++++++- src/pages/PlaylistDetail.tsx | 27 +- src/pages/RandomMix.tsx | 2 +- src/store/analysisSync.ts | 5 +- src/store/audioEventHandlers.ts | 30 +- src/store/authCacheStorageActions.ts | 4 + src/store/authStore.settings.test.ts | 1 + src/store/authStore.ts | 4 +- src/store/authStoreRehydrate.ts | 24 + src/store/authStoreTypes.ts | 7 + src/store/b1QueueServerIdentity.test.ts | 6 +- src/store/favoritesOfflineSyncStore.ts | 19 + src/store/hotCacheStore.count.test.ts | 38 + src/store/hotCacheStore.ts | 318 +--- src/store/hotCacheStoreTypes.ts | 6 + src/store/localPlaybackKeys.ts | 12 + src/store/localPlaybackMigration.ts | 126 ++ src/store/localPlaybackStore.ts | 361 +++++ src/store/offlineJobStore.test.ts | 26 +- src/store/offlineJobStore.ts | 43 + src/store/offlineStore.ts | 535 ++++--- src/store/pendingStarSync.test.ts | 10 +- src/store/pendingStarSync.ts | 14 +- src/store/playTrackAction.ts | 133 +- src/store/playbackUrlRouting.test.ts | 18 +- src/store/playbackUrlRouting.ts | 6 +- src/store/playerStore.persistence.test.ts | 3 + src/store/playerStoreTypes.ts | 2 + src/store/promoteStreamCache.test.ts | 144 +- src/store/promoteStreamCache.ts | 45 +- src/store/queueMutationActions.ts | 26 +- src/store/queueSync.test.ts | 14 +- src/store/queueSync.ts | 12 +- src/store/queueUndoAudioRestore.ts | 10 +- .../components/favorites-offline-control.css | 61 + src/styles/components/index.css | 1 + .../components/offline-cache-button.css | 5 + src/styles/components/offline-library.css | 120 +- .../cache/ephemeralTierReconcile.test.ts | 66 + src/utils/cache/ephemeralTierReconcile.ts | 67 + src/utils/cardGridLayout.test.ts | 5 + src/utils/cardGridLayout.ts | 4 +- .../runArtistDetailActions.ts | 6 +- src/utils/dedupeById.ts | 7 +- src/utils/imageCache/idbStore.ts | 6 +- src/utils/library/advancedSearchLocal.ts | 1 + src/utils/library/patchOnUse.ts | 2 + src/utils/library/queueItemRef.test.ts | 16 + src/utils/library/queueItemRef.ts | 4 +- src/utils/library/queueTrackResolver.test.ts | 6 +- src/utils/library/queueTrackResolver.ts | 17 +- src/utils/media/mediaDir.ts | 7 + src/utils/media/mediaLayout.test.ts | 52 + src/utils/media/mediaLayout.ts | 97 ++ .../legacyOfflineFileMigration.test.ts | 90 ++ .../migrations/legacyOfflineFileMigration.ts | 230 +++ .../navigation/detailServerScope.test.ts | 30 + src/utils/navigation/detailServerScope.ts | 25 + .../network/activeServerReachability.test.ts | 36 + src/utils/network/activeServerReachability.ts | 32 + .../network/subsonicNetworkGuard.test.ts | 40 + src/utils/network/subsonicNetworkGuard.ts | 24 + .../offline/favoritesOfflineBrowse.test.ts | 239 +++ src/utils/offline/favoritesOfflineBrowse.ts | 252 +++ .../offline/favoritesOfflineConstants.test.ts | 8 + .../offline/favoritesOfflineConstants.ts | 2 + .../offline/favoritesOfflineSync.test.ts | 130 ++ src/utils/offline/favoritesOfflineSync.ts | 399 +++++ src/utils/offline/libraryTierReconcile.ts | 272 ++++ .../offline/offlineLibraryHelpers.test.ts | 390 ++++- src/utils/offline/offlineLibraryHelpers.ts | 615 ++++++- src/utils/offline/offlinePinQueue.test.ts | 196 +++ src/utils/offline/offlinePinQueue.ts | 147 ++ src/utils/offline/pinnedOfflineSync.test.ts | 485 ++++++ src/utils/offline/pinnedOfflineSync.ts | 642 ++++++++ .../resumeIncompleteOfflinePins.test.ts | 98 ++ .../offline/resumeIncompleteOfflinePins.ts | 100 ++ src/utils/playback/playAlbum.ts | 18 +- src/utils/playback/playbackServer.test.ts | 34 +- src/utils/playback/playbackServer.ts | 116 +- src/utils/playback/resolvePlaybackUrl.test.ts | 114 +- src/utils/playback/resolvePlaybackUrl.ts | 41 +- src/utils/playback/songToTrack.ts | 3 + src/utils/playback/trackServerScope.test.ts | 79 + src/utils/playback/trackServerScope.ts | 70 + .../server/rewriteFrontendStoreKeys.test.ts | 119 +- src/utils/server/rewriteFrontendStoreKeys.ts | 60 +- 228 files changed, 11551 insertions(+), 1534 deletions(-) create mode 100644 src-tauri/crates/psysonic-core/src/media_layout.rs create mode 100644 src-tauri/crates/psysonic-syncfs/src/cache/local.rs create mode 100644 src/components/OfflineLibraryDiskStat.tsx create mode 100644 src/components/favorites/FavoritesOfflineHeader.tsx create mode 100644 src/cover/serverScope.test.ts create mode 100644 src/cover/serverScope.ts create mode 100644 src/hooks/useAlbumOfflineState.test.ts create mode 100644 src/hooks/useArtistOfflineState.test.ts create mode 100644 src/hooks/useArtistOfflineState.ts create mode 100644 src/hooks/useFavoritesOfflineStatus.ts create mode 100644 src/localPlaybackInvalidation.ts create mode 100644 src/store/favoritesOfflineSyncStore.ts create mode 100644 src/store/hotCacheStore.count.test.ts create mode 100644 src/store/hotCacheStoreTypes.ts create mode 100644 src/store/localPlaybackKeys.ts create mode 100644 src/store/localPlaybackMigration.ts create mode 100644 src/store/localPlaybackStore.ts create mode 100644 src/styles/components/favorites-offline-control.css create mode 100644 src/utils/cache/ephemeralTierReconcile.test.ts create mode 100644 src/utils/cache/ephemeralTierReconcile.ts create mode 100644 src/utils/library/queueItemRef.test.ts create mode 100644 src/utils/media/mediaDir.ts create mode 100644 src/utils/media/mediaLayout.test.ts create mode 100644 src/utils/media/mediaLayout.ts create mode 100644 src/utils/migrations/legacyOfflineFileMigration.test.ts create mode 100644 src/utils/migrations/legacyOfflineFileMigration.ts create mode 100644 src/utils/navigation/detailServerScope.test.ts create mode 100644 src/utils/navigation/detailServerScope.ts create mode 100644 src/utils/network/activeServerReachability.test.ts create mode 100644 src/utils/network/activeServerReachability.ts create mode 100644 src/utils/network/subsonicNetworkGuard.test.ts create mode 100644 src/utils/network/subsonicNetworkGuard.ts create mode 100644 src/utils/offline/favoritesOfflineBrowse.test.ts create mode 100644 src/utils/offline/favoritesOfflineBrowse.ts create mode 100644 src/utils/offline/favoritesOfflineConstants.test.ts create mode 100644 src/utils/offline/favoritesOfflineConstants.ts create mode 100644 src/utils/offline/favoritesOfflineSync.test.ts create mode 100644 src/utils/offline/favoritesOfflineSync.ts create mode 100644 src/utils/offline/libraryTierReconcile.ts create mode 100644 src/utils/offline/offlinePinQueue.test.ts create mode 100644 src/utils/offline/offlinePinQueue.ts create mode 100644 src/utils/offline/pinnedOfflineSync.test.ts create mode 100644 src/utils/offline/pinnedOfflineSync.ts create mode 100644 src/utils/offline/resumeIncompleteOfflinePins.test.ts create mode 100644 src/utils/offline/resumeIncompleteOfflinePins.ts create mode 100644 src/utils/playback/trackServerScope.test.ts create mode 100644 src/utils/playback/trackServerScope.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 80646d41..564d6238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Offline — unified local playback, library index join, and favorites sync + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)** + +* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket. +* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution. +* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header. +* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind. +* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically. +* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist. +* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count. + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f571f8c0..c1416e94 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4266,6 +4266,7 @@ dependencies = [ "psysonic-analysis", "psysonic-audio", "psysonic-core", + "psysonic-library", "reqwest", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47b6a3c3..6635c93a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" version = "1.48.0-dev" edition = "2021" rust-version = "1.95" +license = "GPL-3.0-or-later" [workspace.dependencies] tempfile = "3" @@ -18,7 +19,7 @@ name = "psysonic" version.workspace = true description = "Psysonic Desktop Music Player" authors = [] -license = "" +license.workspace = true repository = "" default-run = "psysonic" edition.workspace = true diff --git a/src-tauri/about.toml b/src-tauri/about.toml index 719305e2..b8b3438e 100644 --- a/src-tauri/about.toml +++ b/src-tauri/about.toml @@ -21,6 +21,8 @@ accepted = [ "OpenSSL", "BSL-1.0", "CDLA-Permissive-2.0", + "GPL-3.0-or-later", + "bzip2-1.0.6", ] # Skip the build host's own platform-pinning; we want a list across all targets @@ -38,5 +40,4 @@ targets = [ ignore-build-dependencies = false ignore-dev-dependencies = true ignore-transitive-dependencies = false -filter-noassertion = false workarounds = ["ring"] diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml index bf6cde10..2cf9dfc6 100644 --- a/src-tauri/crates/psysonic-analysis/Cargo.toml +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-analysis" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 874e7a06..66399c50 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file( enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await } +/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key, +/// plan enrichment under the library UUID, and skip work when both scopes are complete. +pub async fn enqueue_offline_library_analysis_from_file( + app: &tauri::AppHandle, + server_index_key: &str, + library_server_id: &str, + track_id: &str, + file_path: &std::path::Path, + explicit_priority: Option, +) -> Result<(), String> { + use tokio::io::AsyncReadExt; + + use crate::track_analysis_plan::plan_track_analysis_offline_library; + + let mut file = tokio::fs::File::open(file_path) + .await + .map_err(|e| e.to_string())?; + let mut prefix = vec![0u8; 16384]; + let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?; + prefix.truncate(n); + if prefix.is_empty() { + return Ok(()); + } + let content_hash = analysis_cache::md5_first_16kb(&prefix); + let plan = plan_track_analysis_offline_library( + app, + &[server_index_key, library_server_id], + library_server_id, + track_id, + &content_hash, + ); + if !plan.any() { + crate::app_deprintln!( + "[analysis] offline library seed skip (complete) track_id={} index={} library={}", + track_id, + server_index_key, + library_server_id, + ); + return Ok(()); + } + let bytes = tokio::fs::read(file_path) + .await + .map_err(|e| e.to_string())?; + let format_hint = file_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .filter(|e| !e.is_empty()); + let priority = explicit_priority.unwrap_or_else(|| { + analysis_backfill_resolve_priority(app, server_index_key, track_id, None) + }); + enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue { + app, + cache_server_id: server_index_key, + enrichment_server_id: library_server_id, + track_id, + bytes: &bytes, + format_hint: format_hint.as_deref(), + priority, + plan, + fetch_ms: 0, + }) + .await?; + Ok(()) +} + +struct OfflineLibraryAnalysisEnqueue<'a> { + app: &'a tauri::AppHandle, + cache_server_id: &'a str, + enrichment_server_id: &'a str, + track_id: &'a str, + bytes: &'a [u8], + format_hint: Option<&'a str>, + priority: AnalysisBackfillPriority, + plan: psysonic_core::track_analysis::TrackAnalysisPlan, + fetch_ms: u64, +} + +async fn enqueue_track_analysis_offline_library_with_plan( + args: OfflineLibraryAnalysisEnqueue<'_>, +) -> Result { + if args.bytes.is_empty() || !args.plan.any() { + return Ok(EnqueueTrackAnalysisOutcome::Complete); + } + let content_hash = analysis_cache::md5_first_16kb(args.bytes); + if args.plan.needs_full_cpu_seed() { + crate::app_deprintln!( + "[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}", + args.track_id, + content_hash, + args.plan.need_waveform, + args.plan.need_loudness, + args.plan.enrichment.any() + ); + submit_analysis_cpu_seed( + args.app.clone(), + args.cache_server_id.to_string(), + args.track_id.to_string(), + args.bytes.to_vec(), + args.format_hint.map(str::to_string), + args.priority, + args.fetch_ms, + ) + .await?; + return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed); + } + if args.plan.needs_enrichment_only() { + crate::app_deprintln!( + "[analysis] enrichment-only track_id={} hash={}", + args.track_id, + content_hash + ); + let bpm_started = std::time::Instant::now(); + let outcome = run_track_enrichment_from_bytes( + args.app, + args.enrichment_server_id, + args.track_id, + args.bytes, + analysis_emits_ui_events(args.priority), + ) + .await; + if matches!(outcome, TrackEnrichmentOutcome::Failed) { + if let Some(cache) = args.app.try_state::() { + let key = analysis_cache::TrackKey { + server_id: args.cache_server_id.to_string(), + track_id: args.track_id.to_string(), + md5_16kb: content_hash.clone(), + }; + let _ = cache.touch_track_status(&key, "failed"); + } + return Err("track enrichment failed".to_string()); + } + let bpm_ms = bpm_started.elapsed().as_millis() as u64; + emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms); + return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly); + } + Ok(EnqueueTrackAnalysisOutcome::Complete) +} + /// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`]. pub async fn enqueue_analysis_seed( app: &tauri::AppHandle, diff --git a/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs b/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs index 3761fb84..fcb1660a 100644 --- a/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs +++ b/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs @@ -15,8 +15,21 @@ pub fn plan_track_analysis( track_id: &str, content_hash: &str, ) -> TrackAnalysisPlan { - let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash); - let enrichment = enrichment_plan(app, server_id, track_id, content_hash); + plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash) +} + +/// Offline/library download: waveform cache and enrichment facts may live under the +/// playback index key while library rows use the UUID — try every scope before seeding. +pub fn plan_track_analysis_offline_library( + app: &AppHandle, + cache_server_ids: &[&str], + _enrichment_server_id: &str, + track_id: &str, + content_hash: &str, +) -> TrackAnalysisPlan { + let (need_waveform, need_loudness) = + cache_gaps_multi(app, cache_server_ids, track_id, content_hash); + let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash); TrackAnalysisPlan { need_waveform, need_loudness, @@ -103,6 +116,32 @@ fn cache_gaps( ) } +fn cache_gaps_multi( + app: &AppHandle, + server_ids: &[&str], + track_id: &str, + content_hash: &str, +) -> (bool, bool) { + let mut need_waveform = true; + let mut need_loudness = true; + for &server_id in server_ids { + if server_id.is_empty() { + continue; + } + let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash); + if !nw { + need_waveform = false; + } + if !nl { + need_loudness = false; + } + if !need_waveform && !need_loudness { + break; + } + } + (need_waveform, need_loudness) +} + fn enrichment_plan( app: &AppHandle, server_id: &str, @@ -117,6 +156,45 @@ fn enrichment_plan( .unwrap_or_default() } +fn enrichment_plan_multi( + app: &AppHandle, + server_ids: &[&str], + track_id: &str, + content_hash: &str, +) -> psysonic_core::track_enrichment::TrackEnrichmentPlan { + let mut need_bpm = true; + let mut need_valence = true; + let mut need_arousal = true; + let mut need_moods = true; + for &server_id in server_ids { + if server_id.is_empty() { + continue; + } + let plan = enrichment_plan(app, server_id, track_id, content_hash); + if !plan.need_bpm { + need_bpm = false; + } + if !plan.need_valence { + need_valence = false; + } + if !plan.need_arousal { + need_arousal = false; + } + if !plan.need_moods { + need_moods = false; + } + if !need_bpm && !need_valence && !need_arousal && !need_moods { + break; + } + } + psysonic_core::track_enrichment::TrackEnrichmentPlan { + need_bpm, + need_valence, + need_arousal, + need_moods, + } +} + fn cache_gaps_for_content( cache: Option<&AnalysisCache>, server_id: &str, @@ -194,4 +272,14 @@ mod tests { assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint"); } + #[test] + fn playback_index_cache_row_not_visible_under_library_uuid_only() { + let cache = AnalysisCache::open_in_memory(); + seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc"); + let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc"); + assert!(wf && ld, "library uuid alone should miss playback-scoped cache"); + let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc"); + assert!(!wf2 && !ld2, "playback index key should hit the cached row"); + } + } diff --git a/src-tauri/crates/psysonic-audio/Cargo.toml b/src-tauri/crates/psysonic-audio/Cargo.toml index c9dcfda5..4e18e16c 100644 --- a/src-tauri/crates/psysonic-audio/Cargo.toml +++ b/src-tauri/crates/psysonic-audio/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-audio" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-core/Cargo.toml b/src-tauri/crates/psysonic-core/Cargo.toml index 201f7c64..9b87bd35 100644 --- a/src-tauri/crates/psysonic-core/Cargo.toml +++ b/src-tauri/crates/psysonic-core/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-core" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs index e23524a7..0159fc39 100644 --- a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs +++ b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs @@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool { || id.starts_with("ra-") } +/// Windows reserved device names (case-insensitive) — invalid as path components. +const WINDOWS_RESERVED_NAMES: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + /// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe). +/// Also used for media layout artist/album/title segments from server metadata. pub fn sanitize_path_segment(segment: &str) -> String { const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|']; - let trimmed = segment.trim(); + let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string(); if trimmed.is_empty() { return "_".to_string(); } - trimmed + let cleaned: String = trimmed .chars() - .map(|c| if FORBIDDEN.contains(&c) { '_' } else { c }) - .collect() + .map(|c| { + if c.is_control() || FORBIDDEN.contains(&c) { + '_' + } else { + c + } + }) + .collect(); + if cleaned.is_empty() || cleaned == "." || cleaned == ".." { + return "_".to_string(); + } + let upper = cleaned.to_ascii_uppercase(); + if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) { + return format!("_{cleaned}"); + } + cleaned } /// Relative path under `{root}/{server_segment}/` — change format here only. @@ -256,6 +277,13 @@ mod tests { base } + #[test] + fn sanitize_rejects_dot_dot_and_reserved_names() { + assert_eq!(sanitize_path_segment(".."), "_"); + assert_eq!(sanitize_path_segment("CON"), "_CON"); + assert_eq!(sanitize_path_segment(" trailing. "), "trailing"); + } + #[test] fn segment_disk_usage_counts_canonical_only() { let server = test_server_dir("usage"); diff --git a/src-tauri/crates/psysonic-core/src/lib.rs b/src-tauri/crates/psysonic-core/src/lib.rs index c28dd60c..5ad81722 100644 --- a/src-tauri/crates/psysonic-core/src/lib.rs +++ b/src-tauri/crates/psysonic-core/src/lib.rs @@ -5,6 +5,7 @@ //! between `psysonic-audio`, `psysonic-analysis`, and other domain crates. pub mod cover_cache_layout; +pub mod media_layout; pub mod logging; pub mod ports; pub mod track_analysis; diff --git a/src-tauri/crates/psysonic-core/src/media_layout.rs b/src-tauri/crates/psysonic-core/src/media_layout.rs new file mode 100644 index 00000000..24880ce6 --- /dev/null +++ b/src-tauri/crates/psysonic-core/src/media_layout.rs @@ -0,0 +1,364 @@ +//! Local playback disk layout — artist/album/track paths from library-index fields. +//! +//! Mirrors the contract in `implementation-spec.md` (local playback unification). +//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL +//! index key; artist/album/filename segments are derived from track metadata only. + +use std::path::{Component, Path, PathBuf}; + +use crate::cover_cache_layout::sanitize_path_segment; + +/// Max length for a single path component after sanitization (Windows budget). +pub const MAX_SEGMENT_LEN: usize = 120; + +/// Inputs required to build hierarchical media paths (library index row projection). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackPathInput { + pub artist: Option, + pub album_artist: Option, + pub album: String, + pub title: String, + pub track_number: Option, + pub disc_number: Option, + pub suffix: Option, + /// When set, used to detect compilation albums from `raw_json` (OpenSubsonic). + pub raw_json: Option, +} + +/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalTier { + Ephemeral, + Library, + /// Auto-synced starred favorites — separate from user-pinned `library/`. + Favorites, +} + +impl LocalTier { + pub fn subdir(self) -> &'static str { + match self { + Self::Ephemeral => "cache", + Self::Library => "library", + Self::Favorites => "favorites", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "ephemeral" | "cache" => Some(Self::Ephemeral), + "library" => Some(Self::Library), + "favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites), + _ => None, + } + } +} + +/// Stable fingerprint for invalidation when library metadata changes (§8 spec). +pub fn layout_fingerprint(input: &TrackPathInput) -> String { + let artist_seg = artist_folder_segment(input); + let album_seg = album_folder_segment(&input.album); + let stem = track_filename_stem(input); + let suffix = input + .suffix + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(""); + let track_n = input.track_number.unwrap_or(0); + let disc_n = input.disc_number.unwrap_or(0); + format!( + "artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}", + input + .album_artist + .as_deref() + .map(str::trim) + .unwrap_or(""), + input.title.trim(), + ) +} + +/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`. +pub fn relative_path_for_track( + server_index_key: &str, + input: &TrackPathInput, + suffix: &str, +) -> PathBuf { + let server_segment = sanitize_path_segment(server_index_key); + let artist = artist_folder_segment(input); + let album = album_folder_segment(&input.album); + let stem = track_filename_stem(input); + let ext = suffix.trim().trim_start_matches('.'); + let filename = if ext.is_empty() { + sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN) + } else { + format!( + "{}.{}", + sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN), + sanitize_path_segment(ext) + ) + }; + PathBuf::from(server_segment) + .join(artist) + .join(album) + .join(filename) +} + +/// Absolute file path: `{media_root}/{tier}/…relative_path…`. +pub fn absolute_track_path( + media_root: &Path, + tier: LocalTier, + server_index_key: &str, + input: &TrackPathInput, + suffix: &str, +) -> PathBuf { + media_root + .join(tier.subdir()) + .join(relative_path_for_track(server_index_key, input, suffix)) +} + +/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`. +pub fn ensure_track_path_within_tier( + media_root: &Path, + tier: LocalTier, + absolute: &Path, +) -> Result<(), String> { + let tier_root = media_root.join(tier.subdir()); + let Ok(rel) = absolute.strip_prefix(&tier_root) else { + return Err(format!( + "path `{}` escapes tier root `{}`", + absolute.display(), + tier_root.display() + )); + }; + for comp in rel.components() { + if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) { + return Err(format!( + "path `{}` contains forbidden component `{comp:?}`", + absolute.display() + )); + } + } + Ok(()) +} + +fn artist_folder_segment(input: &TrackPathInput) -> String { + let artist = input.artist.as_deref().map(str::trim).unwrap_or(""); + let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or(""); + let chosen = if artist.is_empty() || track_is_compilation(input) { + if !album_artist.is_empty() { + album_artist + } else { + "Various Artists" + } + } else { + artist + }; + sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN) +} + +fn album_folder_segment(album: &str) -> String { + let trimmed = album.trim(); + let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed }; + sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN) +} + +fn track_filename_stem(input: &TrackPathInput) -> String { + let title = input.title.trim(); + let title = if title.is_empty() { "Unknown Title" } else { title }; + let track_n = input.track_number.unwrap_or(0).max(0) as u32; + let disc_n = input.disc_number.unwrap_or(1).max(0) as u32; + if disc_n > 1 { + format!("{disc_n:02}-{track_n:02} - {title}") + } else { + format!("{track_n:02} - {title}") + } +} + +fn track_is_compilation(input: &TrackPathInput) -> bool { + if various_artists_label(input.artist.as_deref().unwrap_or("")) { + return true; + } + let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else { + return false; + }; + raw_json_marks_compilation(raw) +} + +/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`. +fn raw_json_marks_compilation(raw: &str) -> bool { + let lower = raw.to_ascii_lowercase(); + lower.contains("\"iscompilation\":true") + || lower.contains("\"iscompilation\": true") + || lower.contains("\"compilation\":true") + || lower.contains("\"compilation\": true") + || lower.contains("\"compilation\":1") + || lower.contains("\"releaseTypes\"") && lower.contains("compilation") +} + +fn various_artists_label(s: &str) -> bool { + let lower = s.trim().to_ascii_lowercase(); + lower.contains("various artists") +} + +fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String { + let sanitized = sanitize_path_segment(segment); + // Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`. + if sanitized.chars().count() <= max_len { + return sanitized; + } + let hash = short_hash(segment); + let keep = max_len.saturating_sub(1 + hash.len()); + let mut out = sanitized.chars().take(keep).collect::(); + out.push('_'); + out.push_str(&hash); + out +} + +/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units). +fn short_hash(s: &str) -> String { + let mut h: u32 = 0; + for unit in s.encode_utf16() { + h = h.wrapping_mul(31).wrapping_add(unit as u32); + } + format!("{:08x}", h) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_input() -> TrackPathInput { + TrackPathInput { + artist: Some("Radiohead".to_string()), + album_artist: None, + album: "OK Computer".to_string(), + title: "Paranoid Android".to_string(), + track_number: Some(6), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + } + } + + #[test] + fn relative_path_uses_library_segments() { + let rel = relative_path_for_track("host:4533", &sample_input(), "mp3"); + assert_eq!( + rel, + PathBuf::from("host_4533") + .join("Radiohead") + .join("OK Computer") + .join("06 - Paranoid Android.mp3") + ); + } + + #[test] + fn multi_disc_adds_disc_prefix() { + let mut input = sample_input(); + input.disc_number = Some(2); + let rel = relative_path_for_track("srv", &input, "flac"); + assert!(rel + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac"))); + } + + #[test] + fn compilation_uses_album_artist_folder() { + let input = TrackPathInput { + artist: Some("Various Artists".to_string()), + album_artist: Some("Original Soundtrack".to_string()), + album: "Film Score".to_string(), + title: "Main Theme".to_string(), + track_number: Some(1), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + }; + let rel = relative_path_for_track("srv", &input, "mp3"); + assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack")); + } + + #[test] + fn empty_artist_falls_back_to_various_artists() { + let input = TrackPathInput { + artist: None, + album_artist: None, + album: "Comp".to_string(), + title: "Song".to_string(), + track_number: Some(1), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + }; + let rel = relative_path_for_track("srv", &input, "mp3"); + assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists")); + } + + #[test] + fn layout_fingerprint_is_stable() { + let a = layout_fingerprint(&sample_input()); + let b = layout_fingerprint(&sample_input()); + assert_eq!(a, b); + assert!(a.contains("Radiohead")); + assert!(a.contains("OK Computer")); + } + + #[test] + fn tier_subdirs_are_fixed() { + assert_eq!(LocalTier::Ephemeral.subdir(), "cache"); + assert_eq!(LocalTier::Library.subdir(), "library"); + assert_eq!(LocalTier::Favorites.subdir(), "favorites"); + assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral)); + assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library)); + assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites)); + } + + #[test] + fn absolute_path_includes_tier() { + let root = Path::new("/media"); + let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3"); + assert!(path.starts_with(root.join("library"))); + } + + #[test] + fn dot_dot_metadata_does_not_escape_tier_root() { + let input = TrackPathInput { + artist: Some("..".to_string()), + album_artist: None, + album: "..".to_string(), + title: "Song".to_string(), + track_number: Some(1), + disc_number: Some(1), + suffix: Some("mp3".to_string()), + raw_json: None, + }; + let root = Path::new("/media"); + let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3"); + assert!(path.starts_with(root.join("library"))); + ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap(); + } + + #[test] + fn short_hash_matches_ts_imul31_utf16() { + // "Radiohead" — same as mediaLayout.test parity anchor. + assert_eq!(short_hash("Radiohead"), "3da68c3b"); + } + + #[test] + fn sanitize_and_truncate_uses_code_point_threshold() { + let cyrillic_a = '\u{0430}'; + let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect(); + assert!(hundred.len() > MAX_SEGMENT_LEN); + assert_eq!(hundred.chars().count(), 100); + assert_eq!( + sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN), + hundred + ); + + let long: String = std::iter::repeat_n(cyrillic_a, 130).collect(); + let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN); + assert!(truncated.ends_with("_eef20600")); + assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN); + } +} diff --git a/src-tauri/crates/psysonic-integration/Cargo.toml b/src-tauri/crates/psysonic-integration/Cargo.toml index 62553ecb..8f866ceb 100644 --- a/src-tauri/crates/psysonic-integration/Cargo.toml +++ b/src-tauri/crates/psysonic-integration/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-integration" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-library/Cargo.toml b/src-tauri/crates/psysonic-library/Cargo.toml index a51ff478..f07adb8a 100644 --- a/src-tauri/crates/psysonic-library/Cargo.toml +++ b/src-tauri/crates/psysonic-library/Cargo.toml @@ -3,6 +3,7 @@ name = "psysonic-library" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 6b51714b..e466a1b1 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album( Ok(rows.iter().map(LibraryTrackDto::from_row).collect()) } +/// Upsert Subsonic API song payloads into the library index so pin/download can +/// build `media/library/…` paths before a full sync has ingested the rows. +#[tauri::command] +pub fn library_upsert_songs_from_api( + runtime: State<'_, LibraryRuntime>, + server_id: String, + songs: Vec, +) -> Result { + use crate::sync::subsonic_song_to_track_row; + use psysonic_integration::subsonic::Song; + + if songs.is_empty() { + return Ok(0); + } + let synced_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs() as i64; + let repo = TrackRepository::new(&runtime.store); + let mut rows = Vec::with_capacity(songs.len()); + for raw in songs { + let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?; + rows.push(subsonic_song_to_track_row( + &server_id, + &song, + &raw, + synced_at, + None, + )); + } + repo.upsert_batch(&rows)?; + Ok(rows.len() as u32) +} + #[tauri::command] pub async fn library_get_artifact( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index 283877c2..3fdf7eb4 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> { }) } + /// All live rows for a Subsonic track id (any server). Used when legacy offline + /// folders name the server by URL index key rather than profile UUID. + pub fn find_live_by_id(&self, track_id: &str) -> Result, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?; + let rows = stmt + .query_map(params![track_id], row_to_track_row)? + .collect::, _>>()?; + Ok(rows) + }) + } + /// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs /// preserve their order in the result; unknown / deleted refs /// are silently dropped (frontend reads `tracks.length` against @@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> { }) } + /// Legacy offline rows keyed by library `server_id` (index key scope). + pub fn list_offline_local_paths( + &self, + server_id: &str, + ) -> Result)>, String> { + self.store.with_read_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1", + )?; + let rows = stmt.query_map(params![server_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + rows.collect::>>() + }) + } + /// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS. /// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`]. pub fn list_analysis_hash_bpm_ids_after( @@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0"; +const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ + album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ + bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ + server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \ + content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \ + FROM track WHERE id = ?1 AND deleted = 0"; + const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \ album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \ bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \ diff --git a/src-tauri/crates/psysonic-syncfs/Cargo.toml b/src-tauri/crates/psysonic-syncfs/Cargo.toml index 3cab3148..ff990b89 100644 --- a/src-tauri/crates/psysonic-syncfs/Cargo.toml +++ b/src-tauri/crates/psysonic-syncfs/Cargo.toml @@ -3,10 +3,12 @@ name = "psysonic-syncfs" version.workspace = true edition.workspace = true rust-version.workspace = true +license.workspace = true publish = false [dependencies] psysonic-core = { path = "../psysonic-core" } +psysonic-library = { path = "../psysonic-library" } psysonic-analysis = { path = "../psysonic-analysis" } psysonic-audio = { path = "../psysonic-audio" } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs b/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs index bf081aa4..d5186ecd 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; /// Recursively sums the size of all files under `root`. /// Missing roots, unreadable directories, and unreadable files are silently skipped. @@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 { total } +/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list. +pub fn collect_regular_files_under(root: &Path) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let rd = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() { + files.push(path); + } + } + } + files +} + +fn normalize_path_for_prefix(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path. +pub fn local_tier_boundary_from_path(path: &Path) -> Option { + let mut current = path.parent()?; + loop { + match current.file_name().and_then(|s| s.to_str()) { + Some("cache") | Some("library") | Some("favorites") => { + return Some(current.to_path_buf()); + } + _ => current = current.parent()?, + } + } +} + /// Walks upward from `start_dir`, removing each empty directory using `remove_dir` /// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the /// boundary is reached, or removal fails. @@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 { /// `boundary` is never removed and is treated as a hard stop. If `start_dir` is /// not under `boundary`, the function is a no-op. pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) { + let boundary_norm = normalize_path_for_prefix(boundary); let mut current = Some(start_dir.to_path_buf()); while let Some(dir) = current { - if dir == boundary || !dir.starts_with(boundary) { + let dir_norm = normalize_path_for_prefix(&dir); + if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) { break; } match std::fs::read_dir(&dir) { @@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) { } } +/// Post-order sweep: removes empty child directories under `root` (never `root` itself). +pub fn prune_empty_subdirs_under(root: &Path) { + if !root.is_dir() { + return; + } + let children: Vec = std::fs::read_dir(root) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + for child in children { + prune_empty_subdirs_under(&child); + let is_empty = std::fs::read_dir(&child) + .map(|mut rd| rd.next().is_none()) + .unwrap_or(false); + if is_empty { + let _ = std::fs::remove_dir(&child); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -87,6 +153,17 @@ mod tests { assert!(path.exists(), "boundary dir must never be removed"); } + #[test] + fn collect_regular_files_under_lists_nested_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.mp3"), b"x").unwrap(); + let sub = dir.path().join("Artist/Album"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("b.flac"), b"yy").unwrap(); + let files = collect_regular_files_under(dir.path()); + assert_eq!(files.len(), 2); + } + #[test] fn prune_empty_dirs_up_to_stops_at_non_empty_parent() { let root = tempfile::tempdir().unwrap(); @@ -98,4 +175,25 @@ mod tests { assert!(!child.exists(), "empty leaf should be pruned"); assert!(parent.exists(), "non-empty parent must stay"); } + + #[test] + fn local_tier_boundary_from_path_finds_cache_root() { + let root = tempfile::tempdir().unwrap(); + let track = root + .path() + .join("vol/media/cache/my.server/Artist/Album/track.flac"); + std::fs::create_dir_all(track.parent().unwrap()).unwrap(); + let boundary = local_tier_boundary_from_path(&track).unwrap(); + assert_eq!(boundary, root.path().join("vol/media/cache")); + } + + #[test] + fn prune_empty_subdirs_under_removes_nested_empty_tree() { + let root = tempfile::tempdir().unwrap(); + let cache = root.path().join("cache").join("srv").join("Artist").join("Album"); + std::fs::create_dir_all(&cache).unwrap(); + prune_empty_subdirs_under(&root.path().join("cache")); + assert!(!cache.exists()); + assert!(root.path().join("cache").exists(), "tier root preserved"); + } } diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/local.rs b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs new file mode 100644 index 00000000..ed4a3de5 --- /dev/null +++ b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs @@ -0,0 +1,1422 @@ +//! Unified local playback download primitive (LP-1). +//! +//! Builds hierarchical paths from the library index row and downloads bytes +//! under `{media}/{cache|library}/…`. Legacy `download_track_hot_cache` / +//! `download_track_offline` remain until LP-2/3 switch call sites. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; + +use psysonic_analysis::analysis_runtime::{ + enqueue_offline_library_analysis_from_file, enqueue_track_analysis, AnalysisBackfillPriority, +}; +use psysonic_audio as audio; +use psysonic_core::cover_cache_layout::sanitize_path_segment; +use psysonic_core::media_layout::{ + absolute_track_path, ensure_track_path_within_tier, layout_fingerprint, LocalTier, + TrackPathInput, +}; +use psysonic_library::repos::TrackRow; +use psysonic_library::{repos::TrackRepository, LibraryRuntime}; +use tauri::{AppHandle, Manager, State}; + +use crate::file_transfer::{finalize_streamed_download, subsonic_http_client}; +use crate::{offline_cancel_flags, DownloadSemaphore}; + +use super::offline::enqueue_analysis_seed_from_file; + +/// Resolved media root `M` — user `mediaDir` or `{app_data}/media/`. +pub fn resolve_media_dir(custom_media_dir: Option<&str>, app: &AppHandle) -> Result { + if let Some(cd) = custom_media_dir.filter(|s| !s.is_empty()) { + let base = std::path::PathBuf::from(cd); + if !base.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + Ok(base) + } else { + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("media")) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalTrackDownloadResult { + pub path: String, + pub size: u64, + pub layout_fingerprint: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTrackProbeResult { + pub path: String, + pub size: u64, + pub layout_fingerprint: String, + pub exists: bool, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryTierDiskHit { + pub track_id: String, + pub path: String, + pub size: u64, + pub layout_fingerprint: String, + pub suffix: String, +} + +struct ResolvedLibraryTrackPath { + file_path: PathBuf, + path_str: String, + layout_fingerprint: String, +} + +fn resolve_library_track_path( + track_id: &str, + server_index_key: &str, + library_server_id: &str, + suffix: &str, + media_dir: Option<&str>, + app: &AppHandle, + runtime: &LibraryRuntime, +) -> Result { + resolve_track_path_for_tier(ResolveTrackPathForTier { + tier: LocalTier::Library, + track_id, + server_index_key, + library_server_id, + suffix, + media_dir, + app, + runtime, + }) +} + +struct ResolveTrackPathForTier<'a> { + tier: LocalTier, + track_id: &'a str, + server_index_key: &'a str, + library_server_id: &'a str, + suffix: &'a str, + media_dir: Option<&'a str>, + app: &'a AppHandle, + runtime: &'a LibraryRuntime, +} + +fn resolve_track_path_for_tier( + args: ResolveTrackPathForTier<'_>, +) -> Result { + let repo = TrackRepository::new(&args.runtime.store); + let Some(row) = repo.find_one(args.library_server_id, args.track_id)? else { + return Err("LIBRARY_TRACK_NOT_FOUND".to_string()); + }; + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let media_root = resolve_media_dir(args.media_dir, args.app)?; + let file_path = absolute_track_path( + &media_root, + args.tier, + args.server_index_key, + &path_input, + args.suffix, + ); + Ok(ResolvedLibraryTrackPath { + path_str: file_path.to_string_lossy().to_string(), + file_path, + layout_fingerprint: fingerprint, + }) +} + +fn normalize_path_key(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +/// Per-track download mutex — serializes concurrent `download_track_local` / +/// `promote_stream_cache_to_local` for the same `(tier, server, track)` so two +/// callers do not stream into the same `.part` file (M5). +fn track_download_locks( +) -> &'static tokio::sync::Mutex>>> { + static LOCKS: OnceLock>>>> = + OnceLock::new(); + LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) +} + +async fn acquire_per_track_download_lock(key: &str) -> tokio::sync::OwnedMutexGuard<()> { + let lock_arc = { + let mut map = track_download_locks().lock().await; + map.entry(key.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + lock_arc.lock_owned().await +} + +fn per_track_download_lock_key(tier: LocalTier, server_index_key: &str, track_id: &str) -> String { + format!("{}:{}:{}", tier.subdir(), server_index_key, track_id) +} + +/// Part file beside the final track; keyed by sanitized `track_id` instead of +/// replacing the media extension so concurrent different-suffix attempts do not +/// share one `{suffix}.part` on the same stem. +fn unique_part_path(file_path: &Path, suffix: &str, track_id: &str) -> PathBuf { + let parent = file_path.parent().unwrap_or_else(|| Path::new(".")); + let safe_id = sanitize_path_segment(track_id); + parent.join(format!("{safe_id}.{suffix}.part")) +} + +fn track_row_to_path_input(row: &psysonic_library::repos::TrackRow) -> TrackPathInput { + TrackPathInput { + artist: row.artist.clone(), + album_artist: row.album_artist.clone(), + album: row.album.clone(), + title: row.title.clone(), + track_number: row.track_number, + disc_number: row.disc_number, + suffix: row.suffix.clone(), + raw_json: Some(row.raw_json.clone()), + } +} + +async fn local_track_hit_if_exists( + file_path: &Path, + path_str: &str, + fingerprint: &str, + app: &AppHandle, + server_index_key: &str, + library_server_id: &str, + track_id: &str, +) -> Result, String> { + if !file_path.is_file() { + return Ok(None); + } + let size = tokio::fs::metadata(file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + let app_seed = app.clone(); + let tid = track_id.to_string(); + let index_key = server_index_key.to_string(); + let library_id = library_server_id.to_string(); + let fp = file_path.to_path_buf(); + tokio::spawn(async move { + let _ = enqueue_offline_library_analysis_from_file( + &app_seed, + &index_key, + &library_id, + &tid, + &fp, + None, + ) + .await; + }); + Ok(Some(LocalTrackDownloadResult { + path: path_str.to_string(), + size, + layout_fingerprint: fingerprint.to_string(), + })) +} + +/// Downloads a track into the unified media layout. Library/Favorites tiers require +/// a library index row (cold miss → `LIBRARY_TRACK_NOT_FOUND`); Ephemeral returns +/// `TRACK_NOT_INDEXED` when the row is missing. Disk scope uses `server_index_key`; +/// SQL lookup uses `library_server_id`. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn download_track_local( + tier: String, + track_id: String, + server_index_key: String, + library_server_id: String, + url: String, + suffix: String, + media_dir: Option, + download_id: Option, + runtime: State<'_, LibraryRuntime>, + dl_sem: State<'_, DownloadSemaphore>, + app: AppHandle, +) -> Result { + let local_tier = LocalTier::parse(&tier).ok_or_else(|| format!("unknown local tier: `{tier}`"))?; + + let resolved = if local_tier == LocalTier::Library || local_tier == LocalTier::Favorites { + resolve_track_path_for_tier(ResolveTrackPathForTier { + tier: local_tier, + track_id: &track_id, + server_index_key: &server_index_key, + library_server_id: &library_server_id, + suffix: &suffix, + media_dir: media_dir.as_deref(), + app: &app, + runtime: &runtime, + })? + } else { + let repo = TrackRepository::new(&runtime.store); + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + return Err("TRACK_NOT_INDEXED".to_string()); + }; + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let file_path = absolute_track_path( + &media_root, + local_tier, + &server_index_key, + &path_input, + &suffix, + ); + ResolvedLibraryTrackPath { + path_str: file_path.to_string_lossy().to_string(), + file_path, + layout_fingerprint: fingerprint, + } + }; + let ResolvedLibraryTrackPath { + file_path, + path_str, + layout_fingerprint: fingerprint, + } = resolved; + + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + ensure_track_path_within_tier(&media_root, local_tier, &file_path) + .map_err(|e| e.to_string())?; + + if let Some(hit) = local_track_hit_if_exists( + &file_path, + &path_str, + &fingerprint, + &app, + &server_index_key, + &library_server_id, + &track_id, + ) + .await? + { + return Ok(hit); + } + + let _track_guard = acquire_per_track_download_lock(&per_track_download_lock_key( + local_tier, + &server_index_key, + &track_id, + )) + .await; + + if let Some(hit) = local_track_hit_if_exists( + &file_path, + &path_str, + &fingerprint, + &app, + &server_index_key, + &library_server_id, + &track_id, + ) + .await? + { + return Ok(hit); + } + + let cancel_flag: Option> = download_id.as_deref().and_then(|id| { + offline_cancel_flags().lock().ok().map(|mut flags| { + flags + .entry(id.to_string()) + .or_insert_with(|| Arc::new(AtomicBool::new(false))) + .clone() + }) + }); + + let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?; + + if cancel_flag.as_ref().is_some_and(|f| f.load(Ordering::Relaxed)) { + return Err("CANCELLED".to_string()); + } + + if let Some(hit) = local_track_hit_if_exists( + &file_path, + &path_str, + &fingerprint, + &app, + &server_index_key, + &library_server_id, + &track_id, + ) + .await? + { + return Ok(hit); + } + + if let Some(parent) = file_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + let client = subsonic_http_client(std::time::Duration::from_secs(120))?; + 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())); + } + + let part_path = unique_part_path(&file_path, &suffix, &track_id); + finalize_streamed_download( + response, + &file_path, + &part_path, + cancel_flag.as_deref(), + ) + .await?; + + enqueue_offline_library_analysis_from_file( + &app, + &server_index_key, + &library_server_id, + &track_id, + &file_path, + None, + ) + .await?; + + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + + Ok(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + }) +} + +/// Scan library-tier bytes on disk and match them to known candidates only +/// (`track_offline.local_path` + canonical paths for `candidate_track_ids`). +#[tauri::command] +pub async fn discover_library_tier_on_disk( + server_index_key: String, + library_server_id: String, + candidate_track_ids: Vec, + media_dir: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let segment = sanitize_path_segment(&server_index_key); + let tier_root = media_root + .join(LocalTier::Library.subdir()) + .join(&segment); + let disk_files: HashSet = if tier_root.is_dir() { + super::fs_utils::collect_regular_files_under(&tier_root) + .into_iter() + .map(|p| normalize_path_key(&p)) + .collect() + } else { + HashSet::new() + }; + if disk_files.is_empty() { + return Ok(Vec::new()); + } + + let repo = TrackRepository::new(&runtime.store); + let mut hits: Vec = Vec::new(); + let mut seen_tracks: HashSet = HashSet::new(); + + let offline_rows = repo.list_offline_local_paths(&library_server_id)?; + + for (track_id, local_path, suffix_opt) in offline_rows { + if seen_tracks.contains(&track_id) { + continue; + } + let path = PathBuf::from(&local_path); + let key = normalize_path_key(&path); + if !disk_files.contains(&key) && !path.is_file() { + continue; + } + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + continue; + }; + let suffix = suffix_opt + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| row.suffix.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .unwrap_or("mp3"); + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let size = tokio::fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0); + seen_tracks.insert(track_id.clone()); + hits.push(LibraryTierDiskHit { + track_id, + path: local_path, + size, + layout_fingerprint: fingerprint, + suffix: suffix.to_string(), + }); + } + + for track_id in candidate_track_ids { + if seen_tracks.contains(&track_id) { + continue; + } + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + continue; + }; + let suffix = row + .suffix + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("mp3"); + let resolved = resolve_library_track_path( + &track_id, + &server_index_key, + &library_server_id, + suffix, + media_dir.as_deref(), + &app, + &runtime, + )?; + let canonical_key = normalize_path_key(&resolved.file_path); + if !disk_files.contains(&canonical_key) && !resolved.file_path.is_file() { + continue; + } + let size = tokio::fs::metadata(&resolved.file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + seen_tracks.insert(track_id.clone()); + hits.push(LibraryTierDiskHit { + track_id, + path: resolved.path_str, + size, + layout_fingerprint: resolved.layout_fingerprint, + suffix: suffix.to_string(), + }); + } + + Ok(hits) +} + +/// Resolve the canonical `library/` path for a track and report on-disk presence only +/// (no download, no analysis seed). +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn probe_library_track_local( + track_id: String, + server_index_key: String, + library_server_id: String, + suffix: String, + media_dir: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, +) -> Result { + let resolved = resolve_library_track_path( + &track_id, + &server_index_key, + &library_server_id, + &suffix, + media_dir.as_deref(), + &app, + &runtime, + )?; + let exists = resolved.file_path.is_file(); + let size = if exists { + tokio::fs::metadata(&resolved.file_path) + .await + .map(|m| m.len()) + .unwrap_or(0) + } else { + 0 + }; + Ok(LibraryTrackProbeResult { + path: resolved.path_str, + size, + layout_fingerprint: resolved.layout_fingerprint, + exists, + }) +} + +async fn prune_orphan_files_under_root(root: &Path, keep_paths: &[String]) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let keep: HashSet = keep_paths + .iter() + .map(|p| normalize_path_key(Path::new(p))) + .collect(); + let mut removed = Vec::new(); + for file in super::fs_utils::collect_regular_files_under(root) { + if keep.contains(&normalize_path_key(&file)) { + continue; + } + if tokio::fs::remove_file(&file).await.is_err() { + continue; + } + removed.push(file.to_string_lossy().to_string()); + if let Some(parent) = file.parent() { + super::fs_utils::prune_empty_dirs_up_to(parent, root); + } + } + super::fs_utils::prune_empty_subdirs_under(root); + removed +} + +/// Remove library-tier files under `{server_index_key}` that are not listed in `keep_paths`. +#[tauri::command] +pub async fn prune_orphan_library_tier_files( + server_index_key: String, + keep_paths: Vec, + media_dir: Option, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let segment = sanitize_path_segment(&server_index_key); + let root = media_root.join(LocalTier::Library.subdir()).join(segment); + Ok(prune_orphan_files_under_root(&root, &keep_paths).await) +} + +struct OrphanCacheFile { + path: PathBuf, + size: u64, + modified: std::time::SystemTime, +} + +/// Delete cache files not in `keep_paths`, oldest mtime first, until total size ≤ `max_bytes`. +async fn evict_orphan_files_under_root_to_fit( + root: &Path, + keep_paths: &[String], + max_bytes: u64, +) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let mut total = super::fs_utils::dir_size_recursive(root); + if total <= max_bytes { + return Vec::new(); + } + + let keep: HashSet = keep_paths + .iter() + .map(|p| normalize_path_key(Path::new(p))) + .collect(); + + let mut orphans: Vec = Vec::new(); + for file in super::fs_utils::collect_regular_files_under(root) { + if keep.contains(&normalize_path_key(&file)) { + continue; + } + let meta = match std::fs::metadata(&file) { + Ok(m) => m, + Err(_) => continue, + }; + orphans.push(OrphanCacheFile { + path: file, + size: meta.len(), + modified: meta.modified().unwrap_or(std::time::UNIX_EPOCH), + }); + } + orphans.sort_by_key(|f| f.modified); + + let mut removed = Vec::new(); + for orphan in orphans { + if total <= max_bytes { + break; + } + if tokio::fs::remove_file(&orphan.path).await.is_err() { + continue; + } + total = total.saturating_sub(orphan.size); + removed.push(orphan.path.to_string_lossy().to_string()); + if let Some(parent) = orphan.path.parent() { + super::fs_utils::prune_empty_dirs_up_to(parent, root); + } + } + super::fs_utils::prune_empty_subdirs_under(root); + removed +} + +/// Evict unindexed ephemeral cache files (oldest first) until tier size ≤ `max_bytes`. +#[tauri::command] +pub async fn evict_ephemeral_cache_orphans_to_fit( + keep_paths: Vec, + max_bytes: u64, + media_dir: Option, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let root = media_root.join(LocalTier::Ephemeral.subdir()); + Ok(evict_orphan_files_under_root_to_fit(&root, &keep_paths, max_bytes).await) +} + +/// Remove ephemeral-tier files under `{media}/cache/` not listed in `keep_paths`. +#[tauri::command] +pub async fn prune_orphan_ephemeral_cache_files( + keep_paths: Vec, + media_dir: Option, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let root = media_root.join(LocalTier::Ephemeral.subdir()); + Ok(prune_orphan_files_under_root(&root, &keep_paths).await) +} + +/// Batch existence probe for reconcile (index rows without on-disk bytes). +#[tauri::command] +pub fn probe_media_files(local_paths: Vec) -> Vec { + local_paths + .iter() + .map(|p| std::path::Path::new(p).is_file()) + .collect() +} + +fn resolve_media_tier_root( + tier: LocalTier, + media_dir: Option<&str>, + app: &AppHandle, +) -> Result { + Ok(resolve_media_dir(media_dir, app)?.join(tier.subdir())) +} + +/// Recursive byte size under `{media}/{cache|library}/`. +#[tauri::command] +pub async fn get_media_tier_size( + tier: String, + media_dir: Option, + app: AppHandle, +) -> u64 { + let local_tier = match LocalTier::parse(&tier) { + Some(t) => t, + None => return 0, + }; + resolve_media_tier_root(local_tier, media_dir.as_deref(), &app) + .map(|root| super::fs_utils::dir_size_recursive(&root)) + .unwrap_or(0) +} + +/// Deletes the entire `{cache|library}/` subtree under the media root. +#[tauri::command] +pub async fn purge_media_tier( + tier: String, + media_dir: Option, + app: AppHandle, +) -> Result<(), String> { + let local_tier = LocalTier::parse(&tier).ok_or_else(|| format!("unknown local tier: `{tier}`"))?; + let root = resolve_media_tier_root(local_tier, media_dir.as_deref(), &app)?; + if root.exists() { + tokio::fs::remove_dir_all(&root) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn prune_parents_after_media_file_delete( + file_path: &Path, + media_dir: Option<&str>, + app: &AppHandle, +) { + let Some(parent) = file_path.parent() else { + return; + }; + if let Some(boundary) = super::fs_utils::local_tier_boundary_from_path(file_path) { + super::fs_utils::prune_empty_dirs_up_to(parent, &boundary); + return; + } + if let Ok(media_root) = resolve_media_dir(media_dir, app) { + for tier in [LocalTier::Ephemeral, LocalTier::Library, LocalTier::Favorites] { + let boundary = media_root.join(tier.subdir()); + super::fs_utils::prune_empty_dirs_up_to(parent, &boundary); + } + } +} + +/// Deletes one media file and prunes empty parents up to the tier root. +#[tauri::command] +pub async fn delete_media_file( + local_path: String, + media_dir: Option, + app: AppHandle, +) -> Result<(), String> { + let file_path = std::path::PathBuf::from(&local_path); + if file_path.is_file() { + tokio::fs::remove_file(&file_path) + .await + .map_err(|e| e.to_string())?; + } + prune_parents_after_media_file_delete(&file_path, media_dir.as_deref(), &app); + Ok(()) +} + +/// Removes empty directories under `{media}/{cache|library}/` (post-eviction sweep). +#[tauri::command] +pub async fn prune_empty_media_tier_dirs( + tier: String, + media_dir: Option, + app: AppHandle, +) -> Result<(), String> { + let local_tier = + LocalTier::parse(&tier).ok_or_else(|| format!("unknown local tier: `{tier}`"))?; + let root = resolve_media_tier_root(local_tier, media_dir.as_deref(), &app)?; + super::fs_utils::prune_empty_subdirs_under(&root); + Ok(()) +} + +/// Promotes stream-cache bytes into `{media}/cache/…` using library-index paths. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn promote_stream_cache_to_local( + track_id: String, + server_index_key: String, + library_server_id: String, + url: String, + suffix: String, + media_dir: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, + state: State<'_, audio::AudioEngine>, +) -> Result, String> { + let repo = TrackRepository::new(&runtime.store); + let Some(row) = repo.find_one(&library_server_id, &track_id)? else { + return Ok(None); + }; + let path_input = track_row_to_path_input(&row); + let fingerprint = layout_fingerprint(&path_input); + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let file_path = absolute_track_path( + &media_root, + LocalTier::Ephemeral, + &server_index_key, + &path_input, + &suffix, + ); + let path_str = file_path.to_string_lossy().to_string(); + + ensure_track_path_within_tier(&media_root, LocalTier::Ephemeral, &file_path) + .map_err(|e| e.to_string())?; + + if file_path.is_file() { + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return Ok(Some(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + })); + } + + let _track_guard = acquire_per_track_download_lock(&per_track_download_lock_key( + LocalTier::Ephemeral, + &server_index_key, + &track_id, + )) + .await; + + if file_path.is_file() { + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return Ok(Some(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + })); + } + + if let Some(parent) = file_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + + let part_path = unique_part_path(&file_path, &suffix, &track_id); + + if let Some(bytes) = audio::take_stream_completed_for_url(&state, &url) { + if let Err(e) = tokio::fs::write(&part_path, &bytes).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(e.to_string()); + } + tokio::fs::rename(&part_path, &file_path) + .await + .map_err(|e| e.to_string())?; + let priority = psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority( + &app, + &library_server_id, + &track_id, + None, + ); + let format_hint = Some(suffix.to_ascii_lowercase()); + let _ = enqueue_track_analysis( + &app, + &library_server_id, + &track_id, + &bytes, + format_hint.as_deref(), + priority, + ) + .await; + } else if let Some(spill_path) = audio::take_stream_completed_spill_for_url(&state, &url) { + if let Err(e) = tokio::fs::rename(&spill_path, &file_path).await { + if let Err(copy_err) = tokio::fs::copy(&spill_path, &file_path).await { + let _ = tokio::fs::remove_file(&spill_path).await; + return Err(format!("promote spill rename: {e}; copy: {copy_err}")); + } + let _ = tokio::fs::remove_file(&spill_path).await; + } + enqueue_analysis_seed_from_file( + &app, + &library_server_id, + &track_id, + &file_path, + Some(AnalysisBackfillPriority::Middle), + ) + .await; + } else { + return Ok(None); + } + + let size = tokio::fs::metadata(&file_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + Ok(Some(LocalTrackDownloadResult { + path: path_str, + size, + layout_fingerprint: fingerprint, + })) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyOfflineMigrationResult { + pub track_id: String, + pub server_index_key: String, + pub path: String, + pub size: u64, + pub layout_fingerprint: String, + pub relocated: bool, + pub skipped_reason: Option, +} + +fn default_legacy_offline_root(app: &AppHandle) -> Option { + app.path().app_data_dir().ok().map(|d| d.join("psysonic-offline")) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyOfflineDiskEntry { + pub server_segment: String, + pub track_id: String, + pub path: String, + pub suffix: String, + pub size_bytes: u64, +} + +fn scan_flat_offline_root(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + if !root.is_dir() { + return out; + } + let Ok(server_dirs) = std::fs::read_dir(root) else { + return out; + }; + for server_entry in server_dirs.flatten() { + let server_path = server_entry.path(); + if !server_path.is_dir() { + continue; + } + let server_segment = server_entry.file_name().to_string_lossy().to_string(); + let Ok(files) = std::fs::read_dir(&server_path) else { + continue; + }; + for file_entry in files.flatten() { + let path = file_entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some((track_id, suffix)) = name.rsplit_once('.') else { + continue; + }; + if track_id.is_empty() || suffix.is_empty() { + continue; + } + let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + out.push(LegacyOfflineDiskEntry { + server_segment: server_segment.clone(), + track_id: track_id.to_string(), + path: path.to_string_lossy().to_string(), + suffix: suffix.to_string(), + size_bytes, + }); + } + } + out +} + +fn legacy_offline_roots( + app: &AppHandle, + custom_offline_dir: Option<&str>, +) -> Vec { + let mut roots = Vec::new(); + if let Some(root) = default_legacy_offline_root(app) { + roots.push(root); + } + if let Some(cd) = custom_offline_dir.filter(|s| !s.is_empty()) { + let custom = std::path::PathBuf::from(cd); + if roots.iter().all(|r| r != &custom) { + roots.push(custom); + } + } + roots +} + +fn server_index_key_from_url(url: &str) -> String { + let trimmed = url.trim().trim_end_matches('/'); + trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + .unwrap_or(trimmed) + .to_string() +} + +fn base_url_for_server(runtime: &LibraryRuntime, server_id: &str) -> Option { + runtime + .sync_sessions + .lock() + .ok() + .and_then(|sessions| sessions.get(server_id).map(|s| s.base_url.clone())) +} + +fn server_index_key_for_disk( + runtime: &LibraryRuntime, + server_id: &str, + disk_segment: &str, +) -> String { + if let Some(url) = base_url_for_server(runtime, server_id) { + let key = server_index_key_from_url(&url); + if !key.is_empty() { + return key; + } + } + disk_segment.to_string() +} + +fn disk_segment_matches(disk_segment: &str, server_id: &str, index_key: &str) -> bool { + if disk_segment == server_id || disk_segment == index_key { + return true; + } + sanitize_path_segment(disk_segment) == sanitize_path_segment(index_key) + || sanitize_path_segment(disk_segment) == sanitize_path_segment(server_id) +} + +fn resolve_track_for_disk_file( + repo: &TrackRepository, + runtime: &LibraryRuntime, + disk_segment: &str, + track_id: &str, +) -> Result, String> { + if let Some(row) = repo.find_one(disk_segment, track_id)? { + let key = server_index_key_for_disk(runtime, &row.server_id, disk_segment); + return Ok(Some((row, key))); + } + let candidates = repo.find_live_by_id(track_id)?; + if candidates.is_empty() { + return Ok(None); + } + for row in &candidates { + let key = server_index_key_for_disk(runtime, &row.server_id, disk_segment); + if disk_segment_matches(disk_segment, &row.server_id, &key) { + return Ok(Some((row.clone(), key))); + } + } + if candidates.len() == 1 { + let row = candidates[0].clone(); + let key = server_index_key_for_disk(runtime, &row.server_id, disk_segment); + return Ok(Some((row, key))); + } + Ok(None) +} + +fn passes_server_filter( + filter: Option<&str>, + disk_segment: &str, + server_index_key: &str, +) -> bool { + let Some(filter) = filter.filter(|s| !s.is_empty()) else { + return true; + }; + disk_segment == filter + || server_index_key == filter + || sanitize_path_segment(disk_segment) == sanitize_path_segment(filter) +} + +/// Move `old_path` → `target_path` when needed (rename, or copy+delete on EXDEV). +async fn relocate_file_to_target( + old_path: &std::path::Path, + target_path: &std::path::Path, +) -> Result { + if old_path == target_path { + return Ok(false); + } + if target_path.is_file() { + if old_path.is_file() && old_path != target_path { + let _ = tokio::fs::remove_file(old_path).await; + } + return Ok(old_path != target_path); + } + if !old_path.is_file() { + return Err("SOURCE_MISSING".to_string()); + } + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + match tokio::fs::rename(old_path, target_path).await { + Ok(()) => Ok(true), + Err(e) if e.raw_os_error() == Some(18) => { + tokio::fs::copy(old_path, target_path) + .await + .map_err(|e| e.to_string())?; + tokio::fs::remove_file(old_path) + .await + .map_err(|e| e.to_string())?; + Ok(true) + } + Err(e) => Err(e.to_string()), + } +} + +fn prune_legacy_offline_parents(old_path: &std::path::Path, app: &AppHandle) { + let Some(legacy_root) = default_legacy_offline_root(app) else { + return; + }; + let Some(parent) = old_path.parent() else { + return; + }; + if parent.starts_with(&legacy_root) { + super::fs_utils::prune_empty_dirs_up_to(parent, &legacy_root); + } +} + +struct RelocateLegacyTrackFile<'a> { + track_id: &'a str, + server_index_key: &'a str, + old_path: &'a Path, + suffix: &'a str, + row: &'a TrackRow, + media_root: &'a Path, + library_boundary: &'a Path, + app: &'a AppHandle, +} + +async fn relocate_legacy_track_file( + args: RelocateLegacyTrackFile<'_>, +) -> LegacyOfflineMigrationResult { + let path_input = track_row_to_path_input(args.row); + let fingerprint = layout_fingerprint(&path_input); + let target_path = absolute_track_path( + args.media_root, + LocalTier::Library, + args.server_index_key, + &path_input, + args.suffix, + ); + let target_str = target_path.to_string_lossy().to_string(); + let old_path_str = args.old_path.to_string_lossy().to_string(); + + if args.old_path.is_file() && args.old_path == target_path { + let size = tokio::fs::metadata(&target_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: target_str, + size, + layout_fingerprint: fingerprint, + relocated: false, + skipped_reason: None, + }; + } + + if target_path.is_file() { + if args.old_path.is_file() && args.old_path != target_path { + let _ = tokio::fs::remove_file(args.old_path).await; + prune_legacy_offline_parents(args.old_path, args.app); + } + let size = tokio::fs::metadata(&target_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + return LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: target_str, + size, + layout_fingerprint: fingerprint, + relocated: args.old_path.is_file(), + skipped_reason: None, + }; + } + + match relocate_file_to_target(args.old_path, &target_path).await { + Ok(relocated) => { + if relocated { + prune_legacy_offline_parents(args.old_path, args.app); + if let Some(parent) = target_path.parent() { + super::fs_utils::prune_empty_dirs_up_to(parent, args.library_boundary); + } + } + let size = if target_path.is_file() { + tokio::fs::metadata(&target_path) + .await + .map(|m| m.len()) + .unwrap_or(0) + } else { + 0 + }; + LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: target_str, + size, + layout_fingerprint: fingerprint, + relocated, + skipped_reason: if target_path.is_file() { + None + } else { + Some("source_missing".to_string()) + }, + } + } + Err(reason) => LegacyOfflineMigrationResult { + track_id: args.track_id.to_string(), + server_index_key: args.server_index_key.to_string(), + path: old_path_str, + size: 0, + layout_fingerprint: fingerprint, + relocated: false, + skipped_reason: Some(reason), + }, + } +} + +/// Scan `psysonic-offline/{segment}/{trackId}.ext`, verify each id in the library +/// index, and relocate live tracks into `{media}/library/…`. +#[tauri::command] +pub async fn migrate_legacy_offline_disk( + media_dir: Option, + custom_offline_dir: Option, + server_index_key_filter: Option, + runtime: State<'_, LibraryRuntime>, + app: AppHandle, +) -> Result, String> { + let media_root = resolve_media_dir(media_dir.as_deref(), &app)?; + let library_boundary = media_root.join(LocalTier::Library.subdir()); + let repo = TrackRepository::new(&runtime.store); + let filter = server_index_key_filter.as_deref(); + + let mut disk_files = Vec::new(); + for root in legacy_offline_roots(&app, custom_offline_dir.as_deref()) { + disk_files.extend(scan_flat_offline_root(&root)); + } + + let mut out = Vec::with_capacity(disk_files.len()); + for file in disk_files { + let suffix = file.suffix.trim().trim_start_matches('.'); + let suffix = if suffix.is_empty() { "mp3" } else { suffix }; + let old_path = std::path::PathBuf::from(&file.path); + + let Some((row, server_index_key)) = + resolve_track_for_disk_file(&repo, &runtime, &file.server_segment, &file.track_id)? + else { + out.push(LegacyOfflineMigrationResult { + track_id: file.track_id, + server_index_key: file.server_segment.clone(), + path: file.path, + size: file.size_bytes, + layout_fingerprint: String::new(), + relocated: false, + skipped_reason: Some("library_track_not_found".to_string()), + }); + continue; + }; + + if !passes_server_filter(filter, &file.server_segment, &server_index_key) { + continue; + } + + out.push( + relocate_legacy_track_file(RelocateLegacyTrackFile { + track_id: &file.track_id, + server_index_key: &server_index_key, + old_path: &old_path, + suffix, + row: &row, + media_root: &media_root, + library_boundary: &library_boundary, + app: &app, + }) + .await, + ); + } + + Ok(out) +} + +#[cfg(test)] +mod migrate_tests { + use super::*; + + #[test] + fn scan_flat_offline_root_lists_track_files() { + let base = std::env::temp_dir().join(format!( + "psysonic-scan-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let track = base.join("my.server").join("abc123.flac"); + std::fs::create_dir_all(track.parent().unwrap()).unwrap(); + std::fs::write(&track, b"x").unwrap(); + let found = scan_flat_offline_root(&base); + assert_eq!(found.len(), 1); + assert_eq!(found[0].track_id, "abc123"); + assert_eq!(found[0].suffix, "flac"); + assert_eq!(found[0].server_segment, "my.server"); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn evict_ephemeral_cache_orphans_to_fit_removes_oldest_first_when_over_budget() { + let base = std::env::temp_dir().join(format!( + "psysonic-ephemeral-evict-age-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let cache = base.join("cache"); + let keep = cache.join("srv").join("keep.flac"); + let old_orphan = cache.join("srv").join("old.flac"); + let new_orphan = cache.join("srv").join("new.flac"); + std::fs::create_dir_all(keep.parent().unwrap()).unwrap(); + std::fs::write(&keep, b"keep").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + std::fs::write(&old_orphan, b"oldorphan!").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + std::fs::write(&new_orphan, b"new!!").unwrap(); + + let removed = + evict_orphan_files_under_root_to_fit(&cache, &[keep.to_string_lossy().to_string()], 10) + .await; + + assert_eq!(removed.len(), 1); + assert!(removed[0].contains("old.flac")); + assert!(keep.is_file()); + assert!(!old_orphan.exists()); + assert!(new_orphan.is_file()); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn evict_ephemeral_cache_orphans_to_fit_noop_when_under_budget() { + let base = std::env::temp_dir().join(format!( + "psysonic-ephemeral-evict-noop-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let cache = base.join("cache"); + let keep = cache.join("srv").join("keep.flac"); + let orphan = cache.join("srv").join("extra.flac"); + std::fs::create_dir_all(keep.parent().unwrap()).unwrap(); + std::fs::write(&keep, b"keep").unwrap(); + std::fs::write(&orphan, b"x").unwrap(); + + let removed = + evict_orphan_files_under_root_to_fit(&cache, &[keep.to_string_lossy().to_string()], 100) + .await; + + assert!(removed.is_empty()); + assert!(orphan.is_file()); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn prune_orphan_ephemeral_cache_removes_untracked_files_and_empty_dirs() { + let base = std::env::temp_dir().join(format!( + "psysonic-ephemeral-prune-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let keep = base + .join("cache") + .join("srv") + .join("Artist") + .join("Album") + .join("01 - Keep.flac"); + let orphan = base + .join("cache") + .join("srv") + .join("Artist") + .join("Album") + .join("02 - Drop.flac"); + let orphan_part = base + .join("cache") + .join("srv") + .join("Other") + .join("stale.flac.part"); + std::fs::create_dir_all(keep.parent().unwrap()).unwrap(); + std::fs::create_dir_all(orphan_part.parent().unwrap()).unwrap(); + std::fs::write(&keep, b"keep").unwrap(); + std::fs::write(&orphan, b"drop").unwrap(); + std::fs::write(&orphan_part, b"part").unwrap(); + + let removed = prune_orphan_files_under_root( + &base.join("cache"), + &[keep.to_string_lossy().to_string()], + ) + .await; + + assert_eq!(removed.len(), 2); + assert!(keep.is_file()); + assert!(!orphan.exists()); + assert!(!orphan_part.exists()); + assert!(!base.join("cache/srv/Other").exists()); + let _ = std::fs::remove_dir_all(&base); + } + + #[tokio::test(flavor = "multi_thread")] + async fn relocate_moves_file_to_nested_target() { + let base = std::env::temp_dir().join(format!( + "psysonic-migrate-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + let old = base.join("psysonic-offline").join("srv").join("t1.mp3"); + let target = base + .join("media") + .join("library") + .join("Artist") + .join("Album") + .join("01 - Song.mp3"); + std::fs::create_dir_all(old.parent().unwrap()).unwrap(); + std::fs::write(&old, b"abc").unwrap(); + let relocated = relocate_file_to_target(&old, &target).await.unwrap(); + assert!(relocated); + assert!(target.is_file()); + assert!(!old.exists()); + let _ = std::fs::remove_dir_all(&base); + } +} + diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs b/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs index c8f0f071..75c26f78 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/mod.rs @@ -2,3 +2,4 @@ mod fs_utils; pub mod offline; pub mod downloads; pub mod hot; +pub mod local; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d46d5ddd..ed8c2af4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -715,6 +715,7 @@ pub fn run() { psysonic_library::commands::library_get_track, psysonic_library::commands::library_get_tracks_batch, psysonic_library::commands::library_get_tracks_by_album, + psysonic_library::commands::library_upsert_songs_from_api, psysonic_library::commands::library_get_artifact, psysonic_library::commands::library_get_facts, psysonic_library::commands::library_get_offline_path, @@ -775,6 +776,19 @@ pub fn run() { psysonic_syncfs::cache::offline::clear_offline_cancel, psysonic_syncfs::cache::offline::delete_offline_track, psysonic_syncfs::cache::offline::get_offline_cache_size, + psysonic_syncfs::cache::local::download_track_local, + psysonic_syncfs::cache::local::probe_library_track_local, + psysonic_syncfs::cache::local::discover_library_tier_on_disk, + psysonic_syncfs::cache::local::prune_orphan_library_tier_files, + psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files, + psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit, + psysonic_syncfs::cache::local::probe_media_files, + psysonic_syncfs::cache::local::get_media_tier_size, + psysonic_syncfs::cache::local::purge_media_tier, + psysonic_syncfs::cache::local::delete_media_file, + psysonic_syncfs::cache::local::prune_empty_media_tier_dirs, + psysonic_syncfs::cache::local::promote_stream_cache_to_local, + psysonic_syncfs::cache::local::migrate_legacy_offline_disk, psysonic_syncfs::cache::hot::download_track_hot_cache, psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache, psysonic_syncfs::cache::hot::get_hot_cache_size, diff --git a/src/api/library.ts b/src/api/library.ts index 880efbfd..7f67825f 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -473,6 +473,18 @@ export function libraryGetTrack( .then(track => (track ? { ...track, serverId } : track)); } +/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */ +export function libraryUpsertSongsFromApi( + serverId: string, + songs: unknown[], +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_upsert_songs_from_api', { serverId: indexKey, songs }); +} + +/** `library_get_tracks_batch` cap (spec §7.1). */ +export const LIBRARY_TRACKS_BATCH_LIMIT = 100; + export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { const indexKeyMap = new Map(); const remapped = refs.map(ref => { @@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise { + if (refs.length === 0) return []; + const out: LibraryTrackDto[] = []; + for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) { + const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT); + const batch = await libraryGetTracksBatch(chunk).catch(() => []); + out.push(...batch); + } + return out; +} + export function libraryGetTracksByAlbum( serverId: string, albumId: string, diff --git a/src/api/subsonic.async.test.ts b/src/api/subsonic.async.test.ts index 3a7bb35b..7f617e66 100644 --- a/src/api/subsonic.async.test.ts +++ b/src/api/subsonic.async.test.ts @@ -20,6 +20,11 @@ vi.mock('axios', () => ({ default: { get: vi.fn() }, })); +vi.mock('../utils/network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForActiveServer: () => true, + shouldAttemptSubsonicForServer: () => true, +})); + import axios from 'axios'; import { pingWithCredentials, ping } from './subsonic'; import { getAlbumInfo2 } from './subsonicAlbumInfo'; diff --git a/src/api/subsonicLibrary.ts b/src/api/subsonicLibrary.ts index dcb4445a..386117e0 100644 --- a/src/api/subsonicLibrary.ts +++ b/src/api/subsonicLibrary.ts @@ -1,4 +1,8 @@ import { useAuthStore } from '../store/authStore'; +import { + shouldAttemptSubsonicForActiveServer, + shouldAttemptSubsonicForServer, +} from '../utils/network/subsonicNetworkGuard'; import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; import type { RandomSongsFilters, @@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise { } export async function getRandomAlbums(size = 6): Promise { + if (!shouldAttemptSubsonicForActiveServer()) return []; const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size, @@ -71,6 +76,7 @@ export async function getAlbumList( offset = 0, extra: Record = {} ): Promise { + if (!shouldAttemptSubsonicForActiveServer()) return []; const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, @@ -212,6 +218,7 @@ export async function getAlbumListForServer( offset = 0, extra: Record = {}, ): Promise { + if (!shouldAttemptSubsonicForServer(serverId)) return []; const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', { type, size, @@ -224,6 +231,7 @@ export async function getAlbumListForServer( } export async function getSong(id: string): Promise { + if (!shouldAttemptSubsonicForActiveServer()) return null; try { const data = await api<{ song: SubsonicSong }>('getSong.view', { id }); return data.song ?? null; @@ -233,6 +241,7 @@ export async function getSong(id: string): Promise { } export async function getSongForServer(serverId: string, id: string): Promise { + if (!shouldAttemptSubsonicForServer(serverId, id)) return null; try { const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id }); return data.song ?? null; @@ -242,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise { + if (!shouldAttemptSubsonicForActiveServer()) { + throw new Error('Subsonic unavailable'); + } const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id }); const { song, ...album } = data.album; return { album, songs: song ?? [] }; @@ -251,6 +263,9 @@ export async function getAlbumForServer( serverId: string, id: string, ): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { + if (!shouldAttemptSubsonicForServer(serverId)) { + throw new Error('Subsonic unavailable'); + } const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id }); const { song, ...album } = data.album; return { album, songs: song ?? [] }; diff --git a/src/api/subsonicPlaylists.ts b/src/api/subsonicPlaylists.ts index 8a24392f..26efd72c 100644 --- a/src/api/subsonicPlaylists.ts +++ b/src/api/subsonicPlaylists.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; -import { api } from './subsonicClient'; +import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard'; +import { api, apiForServer } from './subsonicClient'; import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes'; export async function getPlaylists(includeOrbit = false): Promise { @@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl return { playlist, songs: entry ?? [] }; } +export async function getPlaylistForServer( + serverId: string, + id: string, +): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> { + if (!shouldAttemptSubsonicForServer(serverId)) { + throw new Error('Subsonic unavailable'); + } + const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>( + serverId, + 'getPlaylist.view', + { id }, + ); + const { entry, ...playlist } = data.playlist; + return { playlist, songs: entry ?? [] }; +} + export async function createPlaylist(name: string, songIds?: string[]): Promise { const params: Record = { name }; if (songIds && songIds.length > 0) { @@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount = songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i), }); } + void import('../utils/offline/pinnedOfflineSync') + .then(m => m.schedulePinnedPlaylistSync(id)) + .catch(() => {}); } export async function updatePlaylistMeta( diff --git a/src/api/subsonicScrobble.test.ts b/src/api/subsonicScrobble.test.ts index 8f6cf35a..7478bf75 100644 --- a/src/api/subsonicScrobble.test.ts +++ b/src/api/subsonicScrobble.test.ts @@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({ api: vi.fn(), apiForServer: apiForServerMock, })); +vi.mock('../utils/network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForServer: () => true, +})); describe('subsonicScrobble', () => { beforeEach(() => { diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index 27077337..5333abd6 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -1,6 +1,7 @@ import { api, apiForServer } from './subsonicClient'; import type { SubsonicNowPlaying } from './subsonicTypes'; import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; +import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard'; async function scrobbleOnServer( serverId: string, @@ -8,6 +9,7 @@ async function scrobbleOnServer( submission: boolean, time?: number, ): Promise { + if (!shouldAttemptSubsonicForServer(serverId, id)) return; const params: Record = { id, submission }; if (time !== undefined) params.time = time; await apiForServer(serverId, 'scrobble.view', params); diff --git a/src/api/subsonicStarRating.ts b/src/api/subsonicStarRating.ts index 2f41627a..7ed05c0d 100644 --- a/src/api/subsonicStarRating.ts +++ b/src/api/subsonicStarRating.ts @@ -1,4 +1,4 @@ -import { api, libraryFilterParams } from './subsonicClient'; +import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; import { invalidateEntityUserRatingCaches } from './subsonicRatings'; import { useAuthStore } from '../store/authStore'; import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse'; @@ -15,6 +15,17 @@ import type { SubsonicSong, } from './subsonicTypes'; +function parseStarred2Response(data: { + starred2?: { + artist?: SubsonicArtist[]; + album?: SubsonicAlbum[]; + song?: SubsonicSong[]; + }; +}): StarredResults { + const r = data.starred2 ?? {}; + return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; +} + export async function getStarred(): Promise { const data = await api<{ starred2: { @@ -23,21 +34,50 @@ export async function getStarred(): Promise { song?: SubsonicSong[]; } }>('getStarred2.view', { ...libraryFilterParams() }); - const r = data.starred2 ?? {}; - return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; + return parseStarred2Response(data); +} + +/** Starred entities for an explicit saved server (not necessarily the active one). */ +export async function getStarredForServer(serverId: string): Promise { + const data = await apiForServer<{ + starred2: { + artist?: SubsonicArtist[]; + album?: SubsonicAlbum[]; + song?: SubsonicSong[]; + }; + }>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) }); + return parseStarred2Response(data); +} + +function resolveStarServerId(meta?: StarPatchMeta): string | null { + return meta?.serverId ?? useAuthStore.getState().activeServerId; +} + +async function starApi( + serverId: string | null | undefined, + endpoint: string, + params: Record, +): Promise { + const sid = serverId ?? useAuthStore.getState().activeServerId; + if (!sid) throw new Error('No server for star API'); + if (sid === useAuthStore.getState().activeServerId) { + await api(endpoint, params); + } else { + await apiForServer(sid, endpoint, params); + } } export async function star( id: string, type: 'song' | 'album' | 'artist' = 'album', - _meta?: StarPatchMeta, + meta?: StarPatchMeta, ): Promise { const params: Record = {}; if (type === 'song') params.id = id; if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; - await api('star.view', params); - const serverId = useAuthStore.getState().activeServerId; + const serverId = resolveStarServerId(meta); + await starApi(serverId, 'star.view', params); if (type === 'song') { patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() }); } else if (type === 'album' && serverId) { @@ -45,19 +85,22 @@ export async function star( const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId); void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {}); } + void import('../utils/offline/favoritesOfflineSync') + .then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined)) + .catch(() => {}); } export async function unstar( id: string, type: 'song' | 'album' | 'artist' = 'album', - _meta?: StarPatchMeta, + meta?: StarPatchMeta, ): Promise { const params: Record = {}; if (type === 'song') params.id = id; if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; - await api('unstar.view', params); - const serverId = useAuthStore.getState().activeServerId; + const serverId = resolveStarServerId(meta); + await starApi(serverId, 'unstar.view', params); if (type === 'song') { patchLibraryTrackOnUse(serverId, id, { starredAt: null }); } else if (type === 'album' && serverId) { @@ -65,6 +108,9 @@ export async function unstar( const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId); void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {}); } + void import('../utils/offline/favoritesOfflineSync') + .then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined)) + .catch(() => {}); } export async function setRating(id: string, rating: number): Promise { diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 5e3297bc..011424f4 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -26,6 +26,8 @@ export interface SubsonicAlbum { displayArtist?: string; /** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */ discTitles?: SubsonicDiscTitle[]; + /** Set when favorites are merged across servers (offline favorites tier). */ + serverId?: string; } export interface SubsonicDiscTitle { @@ -90,6 +92,8 @@ export interface SubsonicSong { trackPeak?: number; albumPeak?: number; }; + /** Set when favorites are merged across servers (offline favorites tier). */ + serverId?: string; /** OpenSubsonic: structured composer credit (string for back-compat). */ displayComposer?: string; /** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */ @@ -144,6 +148,8 @@ export interface SubsonicArtist { starred?: string; /** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */ userRating?: number; + /** Set when favorites are merged across servers (offline favorites tier). */ + serverId?: string; } export interface SubsonicGenre { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 97b075b4..446d78ac 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -38,7 +38,11 @@ import { useOrbitHost } from '../hooks/useOrbitHost'; import { useOrbitGuest } from '../hooks/useOrbitGuest'; import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs'; import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup'; +import { + hasOfflineBrowsingContent, +} from '../utils/offline/favoritesOfflineBrowse'; import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState'; import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle'; import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n'; @@ -106,7 +110,12 @@ export function AppShell() { useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineAlbums = useOfflineStore(s => s.albums); - const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); + const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); + const activeServerId = useAuthStore(s => s.activeServerId); + const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId)); + const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled; + const hasManualOfflineContent = hasAnyOfflineAlbums(offlineAlbums); + const hasOfflineContent = hasOfflineBrowsingContent(offlineAlbums); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const perfFlags = usePerfProbeFlags(); @@ -134,7 +143,13 @@ export function AppShell() { document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); }, [location.pathname, location.state]); - useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate); + useOfflineAutoNav( + connStatus, + hasManualOfflineContent, + favoritesOfflineBrowse, + location.pathname, + navigate, + ); useEffect(() => { initializeFromServerQueue(); diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 51014eff..fce705f9 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; +import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation'; +import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync'; +import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync'; +import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins'; +import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration'; +import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; @@ -98,6 +104,28 @@ export default function MainApp() { return initHotCachePrefetch(); }, [migrationReady]); + useEffect(() => { + if (!migrationReady) return undefined; + void (async () => { + await runLegacyOfflineFileMigration(); + const servers = useAuthStore.getState().servers; + for (const server of servers) { + await reconcileLibraryTierForServer(server.id); + } + scheduleResumeIncompleteOfflinePins(); + })(); + const stopInvalidation = initLocalPlaybackInvalidation(); + const stopFavoritesSync = initFavoritesOfflineSync(); + const stopPinnedOfflineSync = initPinnedOfflineSync(); + const stopOfflineResume = initResumeIncompleteOfflinePins(); + return () => { + stopInvalidation(); + stopFavoritesSync(); + stopPinnedOfflineSync(); + stopOfflineResume(); + }; + }, [migrationReady, serverIdsKey]); + useEffect(() => { if (!migrationReady) return; useGlobalShortcutsStore.getState().registerAll(); diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 7c5d2c70..d1cea6dc 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -7,8 +7,9 @@ import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '../store/playerStore'; -import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; +import { useLocalPlaybackStore } from '../store/localPlaybackStore'; +import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers'; import { CoverArtImage } from '../cover/CoverArtImage'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; import { coverStorageKeyFromRef } from '../cover/storageKeys'; @@ -23,6 +24,8 @@ import { LongPressWaveOverlay } from './LongPressWaveOverlay'; import { useDragDrop } from '../contexts/DragDropContext'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; +import { coverServerScopeForServerId } from '../cover/serverScope'; +import { appendServerQuery } from '../utils/navigation/detailServerScope'; interface AlbumCardProps { album: SubsonicAlbum; @@ -63,21 +66,28 @@ function AlbumCard({ }: AlbumCardProps) { const { t } = useTranslation(); const { isHolding, pressBind } = useLongPressAction({ - onShortPress: () => playAlbum(album.id), - onLongPress: () => playAlbumShuffled(album.id), + onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined), + onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined), }); const navigate = useNavigate(); const navigateToAlbum = useNavigateToAlbum(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const enqueue = usePlayerStore(s => s.enqueue); - const serverId = useAuthStore(s => s.activeServerId ?? ''); - const isOffline = useOfflineStore(s => { - const meta = s.albums[`${serverId}:${album.id}`]; - if (!meta || meta.trackIds.length === 0) return false; - return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); - }); + const activeServerId = useAuthStore(s => s.activeServerId ?? ''); + const offlineServerId = album.serverId ?? activeServerId; + const localEntries = useLocalPlaybackStore(s => s.entries); + const isOffline = isOfflinePinComplete(album.id, offlineServerId); + const albumLinkQuery = useMemo( + () => appendServerQuery(linkQuery, album.serverId), + [linkQuery, album.serverId], + ); + void localEntries; const psyDrag = useDragDrop(); - const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve }); + const coverServerScope = useMemo( + () => coverServerScopeForServerId(album.serverId), + [album.serverId], + ); + const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve }); const dragCoverKey = useMemo(() => { if (!coverRef) return ''; const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' }); @@ -88,7 +98,7 @@ function AlbumCard({ const handleClick = (opts?: { shiftKey?: boolean }) => { if (selectionMode) { onToggleSelect?.(album.id, opts); return; } - navigateToAlbum(album.id, { search: linkQuery }); + navigateToAlbum(album.id, { search: albumLinkQuery }); }; return ( diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index a3281cc1..fc9f50a4 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -74,7 +74,7 @@ interface AlbumHeaderProps { resolvedCoverUrl: string | null; isStarred: boolean; downloadProgress: number | null; - offlineStatus: 'none' | 'downloading' | 'cached'; + offlineStatus: 'none' | 'queued' | 'downloading' | 'cached'; offlineProgress: { done: number; total: number } | null; bio: string | null; bioOpen: boolean; @@ -300,6 +300,15 @@ export default function AlbumHeader({
+ ) : offlineStatus === 'queued' ? ( + ) : offlineStatus === 'cached' ? ( ) : offlineStatus === 'cached' ? ( )} - {albums.length > 0 && (() => { - const progress = id ? bulkProgress[id] : undefined; - const isDone = progress && progress.done === progress.total; - const isDownloading = progress && !isDone; - return ( - - ); - })()} + {albums.length > 0 && ( + + )} diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx index b96431b4..ec79fc71 100644 --- a/src/components/contextMenu/AlbumContextItems.tsx +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -61,6 +61,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { const starred = isStarred(album.id, album.starred); setStarredOverride(album.id, !starred); const meta = { + serverId: album.serverId, name: album.name, artist: album.artist, artistId: album.artistId, diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx index 9dfb6325..cf783467 100644 --- a/src/components/contextMenu/ArtistContextItems.tsx +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -54,7 +54,11 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
handleAction(() => { const starred = isStarred(artist.id, artist.starred); setStarredOverride(artist.id, !starred); - const meta = { name: artist.name, albumCount: artist.albumCount }; + const meta = { + serverId: artist.serverId, + name: artist.name, + albumCount: artist.albumCount, + }; return starred ? unstar(artist.id, 'artist', meta) : star(artist.id, 'artist', meta); diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 1215ef8d..7b73f988 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - queueSongStar(song.id, !isStarred(song.id, song.starred)); + queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index 55821081..2aca22eb 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -121,7 +121,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
handleAction(() => { - queueSongStar(song.id, !isStarred(song.id, song.starred)); + queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} @@ -303,7 +303,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => { - queueSongStar(song.id, false); + queueSongStar(song.id, false, song.serverId); })}> {t('contextMenu.unfavorite')}
diff --git a/src/components/favorites/FavoriteSongRow.tsx b/src/components/favorites/FavoriteSongRow.tsx index 5e069a2e..540c39b3 100644 --- a/src/components/favorites/FavoriteSongRow.tsx +++ b/src/components/favorites/FavoriteSongRow.tsx @@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks { startPreview: (song: SubsonicSong) => void; rate: (songId: string, rating: number) => void; remove: (songId: string) => void; - navArtist: (artistId: string) => void; - navAlbum: (albumId: string) => void; + navArtist: (artistId: string, serverId?: string) => void; + navAlbum: (albumId: string, serverId?: string) => void; } interface Props { @@ -100,12 +100,12 @@ function FavoriteSongRow({ ); case 'artist': return (
- { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist} + { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}
); case 'album': return (
- { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album} + { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{song.album}
); case 'genre': return ( diff --git a/src/components/favorites/FavoritesOfflineHeader.tsx b/src/components/favorites/FavoritesOfflineHeader.tsx new file mode 100644 index 00000000..83d22f19 --- /dev/null +++ b/src/components/favorites/FavoritesOfflineHeader.tsx @@ -0,0 +1,74 @@ +import { HardDrive } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../store/authStore'; +import { + useFavoritesOfflineStatus, + type FavoritesOfflineSemaphore, +} from '../../hooks/useFavoritesOfflineStatus'; +import { + disableFavoritesOfflineSync, + scheduleFavoritesOfflineSync, +} from '../../utils/offline/favoritesOfflineSync'; + +function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string { + switch (semaphore) { + case 'red': + return 'favorites.offlineSemaphoreError'; + case 'yellow': + return 'favorites.offlineSemaphoreSyncing'; + case 'green': + return 'favorites.offlineSemaphoreSynced'; + } +} + +export default function FavoritesOfflineHeader() { + const { t } = useTranslation(); + const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled); + const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus(); + + const semaphoreLabel = semaphore + ? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount }) + : undefined; + + return ( +
+ {enabled && semaphore && ( + + )} +
+ + +
+
+ ); +} diff --git a/src/components/favorites/FavoritesSongsTracklist.tsx b/src/components/favorites/FavoritesSongsTracklist.tsx index 27ea9372..a72a5636 100644 --- a/src/components/favorites/FavoritesSongsTracklist.tsx +++ b/src/components/favorites/FavoritesSongsTracklist.tsx @@ -13,6 +13,7 @@ import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; +import { appendServerQuery } from '../../utils/navigation/detailServerScope'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; import { useElementClientHeightById } from '../../hooks/useResizeClientHeight'; import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering'; @@ -127,8 +128,14 @@ export default function FavoritesSongsTracklist({ ), rate: (songId, r) => latest.current.handleRate(songId, r), remove: (songId) => latest.current.removeSong(songId), - navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`), - navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`), + navArtist: (artistId, serverId) => { + const query = appendServerQuery(undefined, serverId); + latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`); + }, + navAlbum: (albumId, serverId) => { + const query = appendServerQuery(undefined, serverId); + latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`); + }, }), []); const listWrapRef = useRef(null); diff --git a/src/components/favorites/TopFavoriteArtists.tsx b/src/components/favorites/TopFavoriteArtists.tsx index 3b53a4e3..ffdebe71 100644 --- a/src/components/favorites/TopFavoriteArtists.tsx +++ b/src/components/favorites/TopFavoriteArtists.tsx @@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react'; import { CoverArtImage } from '../../cover/CoverArtImage'; import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage'; import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes'; +import { coverServerScopeForServerId } from '../../cover/serverScope'; export interface TopFavoriteArtist { id: string; name: string; count: number; coverArtId: string; + /** Present when favorites are merged across servers. */ + serverId?: string; + /** Raw artist id for song filtering (without server prefix). */ + artistId?: string; } interface TopFavoriteArtistsRowProps { @@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps { function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) { const coverId = artist.coverArtId; + const artistEntityId = artist.artistId ?? artist.coverArtId; return (
{coverId ? ( { if (!currentTrack) return; - queueSongStar(currentTrack.id, !isStarred); + queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId); }, [currentTrack, isStarred]); const duration = currentTrack?.duration ?? 0; diff --git a/src/components/playlist/PlaylistHero.tsx b/src/components/playlist/PlaylistHero.tsx index ea40b1b3..a0f68251 100644 --- a/src/components/playlist/PlaylistHero.tsx +++ b/src/components/playlist/PlaylistHero.tsx @@ -7,6 +7,8 @@ import { } from 'lucide-react'; import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes'; import type { ZipDownload } from '../../store/zipDownloadStore'; +import type { AlbumOfflineStatus } from '../../hooks/useAlbumOfflineState'; +import { dequeueOfflinePin } from '../../utils/offline/offlinePinQueue'; import { useThemeStore } from '../../store/themeStore'; import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore'; import { @@ -29,8 +31,7 @@ interface Props { searchOpen: boolean; csvImporting: boolean; activeZip: ZipDownload | undefined; - isCached: boolean; - isDownloading: boolean; + offlineStatus: AlbumOfflineStatus; offlineProgress: { done: number; total: number } | null; activeServerId: string; setEditingMeta: React.Dispatch>; @@ -52,7 +53,7 @@ export default function PlaylistHero({ playlist, songs, id, customCoverId, coverQuadIds, resolvedBgUrl, saving, searchOpen, csvImporting, activeZip, - isCached, isDownloading, offlineProgress, activeServerId, + offlineStatus, offlineProgress, activeServerId, setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults, setSelectedSearchIds, setSearchPlPickerOpen, handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload, @@ -206,27 +207,39 @@ export default function PlaylistHero({ ) )} - {isLayoutVisible('offlineCache') && songs.length > 0 && id && ( + {isLayoutVisible('offlineCache') && songs.length > 0 && id + && (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (