From 25b8b573283137e1309d8c0032ed58a17a999707 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:56:08 +0300 Subject: [PATCH] revert: multi-server library scope (#1309) (#1310) --- CHANGELOG.md | 8 - src-tauri/crates/psysonic-library/Cargo.toml | 2 +- .../psysonic-library/src/advanced_search.rs | 65 +- .../src/artist_lossless_browse.rs | 64 +- .../psysonic-library/src/browse_support.rs | 155 +-- .../crates/psysonic-library/src/commands.rs | 41 - src-tauri/crates/psysonic-library/src/dto.rs | 131 +-- .../src/genre_album_browse.rs | 59 +- .../psysonic-library/src/identity/mod.rs | 1 - src-tauri/crates/psysonic-library/src/lib.rs | 1 - .../psysonic-library/src/live_search.rs | 62 +- .../psysonic-library/src/lossless_albums.rs | 85 +- .../psysonic-library/src/scope_merge.rs | 919 +++--------------- .../psysonic-library/src/scope_statistics.rs | 425 -------- src-tauri/src/lib.rs | 10 - src/app/AppShell.tsx | 2 - src/app/MainApp.tsx | 14 +- src/app/hooks/useServerCapabilitiesProbe.ts | 14 +- .../musicLibraryCatalogReloadBridge.test.ts | 71 -- src/app/musicLibraryCatalogReloadBridge.ts | 53 +- .../tauriBridge/usePlayerSnapshotPublisher.ts | 9 +- src/config/settingsCredits.ts | 1 - src/config/shortcutDispatch.ts | 2 +- src/features/album/components/AlbumCard.tsx | 12 +- src/features/album/components/AlbumHeader.tsx | 3 +- src/features/album/components/AlbumRow.tsx | 5 +- .../album/components/LosslessAlbumsRail.tsx | 20 +- .../hooks/loadAlbumDetailMultiScope.test.ts | 13 +- .../album/hooks/loadAlbumDetailMultiScope.ts | 5 +- .../album/hooks/useAlbumBrowseData.ts | 44 +- .../useAlbumDetailData.multiScope.test.ts | 39 +- .../album/hooks/useAlbumDetailData.ts | 25 +- .../album/hooks/useAlbumOfflineState.test.ts | 2 - .../album/hooks/useAlbumOfflineState.ts | 10 +- .../album/hooks/useBrowseAlbumTextSearch.ts | 17 +- src/features/album/pages/AlbumDetail.tsx | 64 +- src/features/album/pages/Albums.tsx | 35 +- src/features/album/pages/LabelAlbums.tsx | 16 +- src/features/album/pages/LosslessAlbums.tsx | 38 +- src/features/album/pages/MostPlayed.tsx | 47 +- src/features/album/pages/NewReleases.tsx | 71 +- src/features/album/pages/RandomAlbums.tsx | 61 +- .../album/store/albumBrowseSessionStore.ts | 6 +- .../album/utils/albumBrowseCatalogChunk.ts | 4 +- src/features/artist/components/ArtistRow.tsx | 5 +- .../artist/components/ArtistsGridView.tsx | 23 +- .../artist/components/ArtistsListView.tsx | 21 +- .../hooks}/loadArtistDetailMultiScope.test.ts | 13 +- .../hooks}/loadArtistDetailMultiScope.ts | 5 +- .../useArtistDetailData.multiScope.test.ts | 51 +- .../artist/hooks/useArtistDetailData.ts | 31 +- .../artist/hooks/useArtistsBrowseCatalog.ts | 35 +- .../artist/hooks/useBrowseArtistTextSearch.ts | 24 +- src/features/artist/pages/Artists.tsx | 34 +- .../artist/store/artistBrowseSessionStore.ts | 11 +- .../artist/utils/runArtistDetailActions.ts | 36 +- .../composers/pages/ComposerDetail.tsx | 21 +- src/features/composers/pages/Composers.tsx | 22 +- .../components/AddToPlaylistSubmenu.tsx | 21 +- .../components/AlbumContextItems.tsx | 17 +- .../components/ArtistContextItems.tsx | 16 +- .../contextMenu/components/ContextMenu.tsx | 11 +- .../components/MoveToFolderSubmenu.tsx | 5 +- .../components/PlaylistContextItems.tsx | 34 +- .../components/QueueItemContextItems.tsx | 2 +- .../components/SongContextItems.tsx | 15 +- .../components/contextMenuItemTypes.ts | 2 +- .../contextMenu/hooks/useContextMenuRating.ts | 49 +- .../contextMenu/utils/contextMenuActions.ts | 3 +- .../hooks/useFolderBrowserNowPlayingPath.ts | 33 +- .../folderBrowser/pages/FolderBrowser.tsx | 41 +- .../utils/folderBrowserHelpers.source.test.ts | 10 - .../utils/folderBrowserHelpers.ts | 3 - .../components/FullscreenPlayerImmersive.tsx | 4 +- .../components/FullscreenPlayerStatic.tsx | 11 +- src/features/genre/pages/Genres.tsx | 18 +- .../home/components/BecauseYouLikeRail.tsx | 98 +- src/features/home/components/Hero.tsx | 35 +- src/features/home/components/SongCard.tsx | 10 +- src/features/home/components/SongRail.tsx | 5 +- src/features/home/pages/Home.tsx | 173 ++-- .../home/store/becauseYouLikeCache.test.ts | 17 +- .../home/store/becauseYouLikeCache.ts | 8 +- src/features/home/store/homeFeedCache.test.ts | 31 - src/features/home/store/homeFeedCache.ts | 14 +- .../home/utils/homeFeedLoader.test.ts | 140 --- src/features/home/utils/homeFeedLoader.ts | 185 ---- .../miniPlayer/components/MiniContextMenu.tsx | 9 +- .../components/MobilePlayerView.tsx | 7 +- src/features/nowPlaying/pages/NowPlaying.tsx | 3 +- src/features/offline/store/offlineJobStore.ts | 16 +- src/features/offline/store/offlineStore.ts | 1 - .../offline/utils/offlinePinQueue.test.ts | 33 - src/features/offline/utils/offlinePinQueue.ts | 46 +- .../offline/utils/pinnedOfflineSync.ts | 9 +- .../orbit/components/OrbitJoinModal.tsx | 31 +- .../components/OrbitStartTrigger.test.tsx | 59 -- .../orbit/components/OrbitStartTrigger.tsx | 60 +- src/features/orbit/utils/host.test.ts | 22 +- src/features/orbit/utils/host.ts | 12 - src/features/orbit/utils/orbit.ts | 2 - .../PlaybackAlternativeModal.test.tsx | 46 - .../components/PlaybackAlternativeModal.tsx | 87 -- .../playback/components/PlayerBar.tsx | 5 +- .../components/playerBar/PlayerTrackInfo.tsx | 5 +- src/features/playback/index.ts | 3 +- .../playback/store/audioEventHandlers.ts | 23 +- .../playback/store/pendingStarSync.test.ts | 340 ++----- .../playback/store/pendingStarSync.ts | 542 +++-------- .../store/playbackAlternativeStore.test.ts | 210 ---- .../store/playbackAlternativeStore.ts | 199 ---- .../playback/store/playbackErrorSkip.test.ts | 38 - .../playback/store/playbackErrorSkip.ts | 13 - .../playback/store/playerStore.misc.test.ts | 9 +- .../playback/store/playerStoreTypes.ts | 4 +- .../playback/store/queueTrackResolver.test.ts | 11 +- .../playback/store/queueTrackResolver.ts | 27 +- .../playback/store/queueTrackView.test.ts | 6 +- src/features/playback/store/queueTrackView.ts | 3 +- .../playback/store/replayGainMetadataSync.ts | 2 +- .../playback/store/skipStarRating.test.ts | 5 +- src/features/playback/store/skipStarRating.ts | 6 +- src/features/playback/store/uiStateActions.ts | 25 +- .../utils/mixRatingFilter.scope.test.ts | 61 -- .../playback/utils/mixRatingFilter.test.ts | 3 +- .../playback/utils/mixRatingFilter.ts | 39 +- .../playback/genreBrowsePlayback.test.ts | 15 +- .../utils/playback/genreBrowsePlayback.ts | 30 +- .../components/PlaylistArtistCell.tsx | 8 +- .../PlaylistCard.multiServer.test.tsx | 51 - .../playlist/components/PlaylistCard.tsx | 42 +- .../components/PlaylistCoverImages.tsx | 7 +- .../components/PlaylistFolderSection.test.tsx | 21 +- .../components/PlaylistFolderSection.tsx | 6 +- .../playlist/components/PlaylistHero.tsx | 39 +- .../playlist/components/PlaylistRow.tsx | 7 +- .../components/PlaylistSongSearchPanel.tsx | 8 +- .../components/PlaylistSuggestions.tsx | 30 +- .../playlist/components/PlaylistTracklist.tsx | 47 +- .../components/PlaylistsFolderViewToggle.tsx | 6 +- .../hooks/usePendingSmartPolling.test.ts | 50 - .../playlist/hooks/usePendingSmartPolling.ts | 18 +- .../playlist/hooks/usePlaylistCovers.ts | 23 +- .../playlist/hooks/usePlaylistDnDReorder.ts | 10 +- .../playlist/hooks/usePlaylistSelection.ts | 5 +- .../hooks/usePlaylistSongMutations.test.ts | 27 - .../hooks/usePlaylistSongMutations.ts | 10 +- .../playlist/hooks/usePlaylistSongSearch.ts | 7 +- .../playlist/hooks/usePlaylistStarRating.ts | 11 +- .../playlist/hooks/usePlaylistSuggestions.ts | 12 +- .../usePlaylistsLibraryScopeCounts.test.ts | 43 - .../hooks/usePlaylistsLibraryScopeCounts.ts | 27 +- .../hooks/useSmartCoverCollage.test.ts | 38 - .../playlist/hooks/useSmartCoverCollage.ts | 14 +- src/features/playlist/index.ts | 1 - .../playlist/pages/PlaylistDetail.tsx | 105 +- src/features/playlist/pages/Playlists.tsx | 133 +-- .../store/playlistFolderStore.test.ts | 8 +- src/features/playlist/store/playlistStore.ts | 44 +- .../addTracksToPlaylistWithDedup.test.ts | 10 +- .../utils/addTracksToPlaylistWithDedup.ts | 36 +- src/features/playlist/utils/playlistsSmart.ts | 1 - .../resolvePlaylistTargetTrackIds.test.ts | 25 - .../utils/resolvePlaylistTargetTrackIds.ts | 22 - .../utils/resolvePlaylistTracks.test.ts | 15 - .../playlist/utils/resolvePlaylistTracks.ts | 15 +- .../utils/runPlaylistCsvImport.test.ts | 48 - .../playlist/utils/runPlaylistCsvImport.ts | 12 +- .../playlist/utils/runPlaylistLoad.test.ts | 35 +- .../playlist/utils/runPlaylistLoad.ts | 23 +- .../playlist/utils/runPlaylistReorderDrop.ts | 2 +- .../playlist/utils/runPlaylistSaveMeta.ts | 15 +- .../playlist/utils/runPlaylistZipDownload.ts | 7 +- .../playlist/utils/runPlaylistsActions.ts | 41 +- .../utils/runPlaylistsOpenSmartEditor.test.ts | 35 - .../utils/runPlaylistsOpenSmartEditor.ts | 17 +- .../playlist/utils/runPlaylistsSaveSmart.ts | 10 +- .../playlist/utils/startPlaylistRowDrag.ts | 7 +- .../utils/updatePlaylistMembership.test.ts | 104 -- .../utils/updatePlaylistMembership.ts | 85 -- .../queue/components/QueueCurrentTrack.tsx | 3 +- src/features/queue/components/QueueHeader.tsx | 2 +- src/features/queue/components/QueuePanel.tsx | 102 +- .../QueueServerSliceDialog.test.tsx | 65 -- .../components/QueueServerSliceDialog.tsx | 96 -- .../queue/components/QueueToolbar.tsx | 2 +- .../queueServerSliceChoice.test.tsx | 104 -- .../queue/hooks/useQueueTracks.test.ts | 5 +- src/features/queue/utils/queueServerSlices.ts | 45 - .../radio/components/RadioCard.test.tsx | 38 - src/features/radio/components/RadioCard.tsx | 20 +- .../components/RadioDirectoryModal.test.tsx | 97 -- .../radio/components/RadioDirectoryModal.tsx | 77 +- .../radio/components/RadioEditModal.test.tsx | 100 -- .../radio/components/RadioEditModal.tsx | 53 +- src/features/radio/pages/InternetRadio.tsx | 151 +-- .../radio/utils/radioSourceLabels.test.ts | 14 - .../radio/utils/radioStationIdentity.test.ts | 26 - .../radio/utils/radioStationIdentity.ts | 31 - src/features/randomMix/pages/RandomMix.tsx | 27 +- src/features/search/components/LiveSearch.tsx | 18 +- .../search/components/LiveSearchDropdown.tsx | 35 +- .../search/components/MobileSearchOverlay.tsx | 14 +- .../search/components/PagedSongList.tsx | 3 +- src/features/search/components/SongRow.tsx | 19 +- .../search/hooks/useAdvancedSearchRunner.ts | 47 +- .../search/hooks/useLiveSearchQuery.ts | 25 +- .../useSongBrowseList.multiScope.test.ts | 62 -- .../search/hooks/useSongBrowseList.ts | 49 +- .../search/pages/SearchBrowsePage.tsx | 7 +- .../components/ServerLibraryIndexControls.tsx | 2 +- .../settings/components/ServersTab.tsx | 2 +- .../components/PasteClipboardHandler.tsx | 32 +- src/features/sidebar/components/Sidebar.tsx | 72 +- .../components/SidebarLibraryPicker.test.tsx | 168 +--- .../components/SidebarLibraryPicker.tsx | 331 +------ .../components/SidebarNavBody.test.tsx | 23 - .../sidebar/components/SidebarNavBody.tsx | 15 +- .../components/SidebarPlaylistsSection.tsx | 72 +- src/features/stats/pages/Statistics.tsx | 40 +- src/generated/bindings.ts | 66 +- src/lib/api/library/dto.ts | 44 +- src/lib/api/library/internal.ts | 6 +- .../api/library/reads.losslessScope.test.ts | 32 +- src/lib/api/library/reads.ts | 10 +- src/lib/api/library/scopeReads.test.ts | 295 ------ src/lib/api/library/scopeReads.ts | 142 +-- src/lib/api/library/stats.ts | 15 +- src/lib/api/subsonic.async.test.ts | 18 +- src/lib/api/subsonic.contract.test.ts | 8 +- src/lib/api/subsonicClient.ts | 19 +- src/lib/api/subsonicLibrary.ts | 75 -- src/lib/api/subsonicPlaylists.test.ts | 29 +- src/lib/api/subsonicPlaylists.ts | 125 +-- src/lib/api/subsonicRadio.test.ts | 70 -- src/lib/api/subsonicRadio.ts | 79 +- src/lib/api/subsonicSearch.ts | 36 +- src/lib/api/subsonicStarRating.ts | 15 +- src/lib/api/subsonicStatistics.test.ts | 72 +- src/lib/api/subsonicStatistics.ts | 75 +- src/lib/api/subsonicStreamUrl.ts | 17 - src/lib/api/subsonicTypes.ts | 3 - src/lib/hooks/useConnectionStatus.ts | 11 - src/lib/hooks/useModalFocus.ts | 83 -- src/lib/hooks/useNavidromeAdminRole.test.ts | 32 +- src/lib/hooks/useNavidromeAdminRole.ts | 52 +- src/lib/hooks/useRangeSelection.test.ts | 37 - src/lib/hooks/useRangeSelection.ts | 16 +- src/lib/library/advancedSearchLocal.test.ts | 21 - src/lib/library/advancedSearchLocal.ts | 29 +- .../library/albumBrowseGenreCountsCache.ts | 13 +- .../library/albumBrowseGenreOptions.test.ts | 14 +- src/lib/library/albumBrowseLoad.ts | 55 +- src/lib/library/albumBrowseLocal.ts | 4 +- src/lib/library/browseTextSearch.ts | 57 +- .../library/hooks/useLibraryIndexSync.test.ts | 40 - src/lib/library/hooks/useLibraryIndexSync.ts | 104 +- src/lib/library/libraryBrowseScope.test.ts | 186 ---- src/lib/library/libraryBrowseScope.ts | 186 ---- .../library/libraryEntityKey.source.test.ts | 9 - src/lib/library/libraryEntityKey.test.ts | 22 - src/lib/library/libraryEntityKey.ts | 9 - src/lib/library/librarySession.owner.test.ts | 69 -- src/lib/library/librarySession.ts | 30 +- src/lib/library/liveSearchLocal.test.ts | 18 - src/lib/library/liveSearchLocal.ts | 12 +- src/lib/library/randomScopeReads.ts | 32 - src/lib/media/entityOverrideKey.ts | 19 - .../navigation/albumDetailNavigation.test.ts | 14 - src/lib/navigation/detailServerScope.test.ts | 6 - src/lib/navigation/detailServerScope.ts | 8 +- src/lib/network/libraryServerReachability.ts | 32 - src/lib/network/subsonicNetworkGuard.test.ts | 28 +- src/lib/network/subsonicNetworkGuard.ts | 12 - src/lib/server/serverIndexKey.ts | 68 -- src/lib/server/serverIndexOwner.test.ts | 42 - src/lib/server/serverLookup.ts | 17 +- src/lib/share/copyEntityShareLink.test.ts | 19 - src/lib/share/copyEntityShareLink.ts | 15 +- src/lib/util/dedupeById.ts | 6 +- src/locales/bg/entityRating.ts | 2 - src/locales/bg/orbit.ts | 1 - src/locales/bg/player.ts | 12 - src/locales/bg/playlists.ts | 2 - src/locales/bg/queue.ts | 8 - src/locales/bg/radio.ts | 3 - src/locales/bg/sidebar.ts | 18 - src/locales/bg/smartPlaylists.ts | 1 - src/locales/de/entityRating.ts | 2 - src/locales/de/orbit.ts | 1 - src/locales/de/player.ts | 12 - src/locales/de/playlists.ts | 2 - src/locales/de/queue.ts | 8 - src/locales/de/radio.ts | 3 - src/locales/de/sidebar.ts | 18 - src/locales/de/smartPlaylists.ts | 1 - src/locales/en/entityRating.ts | 2 - src/locales/en/orbit.ts | 1 - src/locales/en/player.ts | 12 - src/locales/en/playlists.ts | 2 - src/locales/en/queue.ts | 8 - src/locales/en/radio.ts | 3 - src/locales/en/sidebar.ts | 18 - src/locales/en/smartPlaylists.ts | 1 - src/locales/es/entityRating.ts | 2 - src/locales/es/orbit.ts | 1 - src/locales/es/player.ts | 12 - src/locales/es/playlists.ts | 2 - src/locales/es/queue.ts | 8 - src/locales/es/radio.ts | 3 - src/locales/es/sidebar.ts | 18 - src/locales/es/smartPlaylists.ts | 1 - src/locales/fr/entityRating.ts | 2 - src/locales/fr/orbit.ts | 1 - src/locales/fr/player.ts | 12 - src/locales/fr/playlists.ts | 2 - src/locales/fr/queue.ts | 8 - src/locales/fr/radio.ts | 3 - src/locales/fr/sidebar.ts | 18 - src/locales/fr/smartPlaylists.ts | 1 - src/locales/hu/entityRating.ts | 2 - src/locales/hu/orbit.ts | 1 - src/locales/hu/player.ts | 12 - src/locales/hu/playlists.ts | 2 - src/locales/hu/queue.ts | 8 - src/locales/hu/radio.ts | 3 - src/locales/hu/sidebar.ts | 18 - src/locales/hu/smartPlaylists.ts | 1 - src/locales/it/entityRating.ts | 2 - src/locales/it/orbit.ts | 3 +- src/locales/it/player.ts | 14 +- src/locales/it/playlists.ts | 4 +- src/locales/it/queue.ts | 8 - src/locales/it/radio.ts | 5 +- src/locales/it/sidebar.ts | 18 - src/locales/it/smartPlaylists.ts | 3 +- src/locales/ja/entityRating.ts | 2 - src/locales/ja/orbit.ts | 1 - src/locales/ja/player.ts | 12 - src/locales/ja/playlists.ts | 2 - src/locales/ja/queue.ts | 8 - src/locales/ja/radio.ts | 3 - src/locales/ja/sidebar.ts | 18 - src/locales/ja/smartPlaylists.ts | 1 - src/locales/nb/entityRating.ts | 2 - src/locales/nb/orbit.ts | 1 - src/locales/nb/player.ts | 12 - src/locales/nb/playlists.ts | 2 - src/locales/nb/queue.ts | 8 - src/locales/nb/radio.ts | 3 - src/locales/nb/sidebar.ts | 18 - src/locales/nb/smartPlaylists.ts | 1 - src/locales/nl/entityRating.ts | 2 - src/locales/nl/orbit.ts | 1 - src/locales/nl/player.ts | 12 - src/locales/nl/playlists.ts | 2 - src/locales/nl/queue.ts | 8 - src/locales/nl/radio.ts | 3 - src/locales/nl/sidebar.ts | 18 - src/locales/nl/smartPlaylists.ts | 1 - src/locales/pl/entityRating.ts | 2 - src/locales/pl/orbit.ts | 1 - src/locales/pl/player.ts | 12 - src/locales/pl/playlists.ts | 2 - src/locales/pl/queue.ts | 8 - src/locales/pl/radio.ts | 3 - src/locales/pl/sidebar.ts | 18 - src/locales/pl/smartPlaylists.ts | 1 - src/locales/ro/entityRating.ts | 2 - src/locales/ro/orbit.ts | 1 - src/locales/ro/player.ts | 12 - src/locales/ro/playlists.ts | 2 - src/locales/ro/queue.ts | 8 - src/locales/ro/radio.ts | 3 - src/locales/ro/sidebar.ts | 18 - src/locales/ro/smartPlaylists.ts | 1 - src/locales/ru/entityRating.ts | 2 - src/locales/ru/orbit.ts | 1 - src/locales/ru/player.ts | 12 - src/locales/ru/playlists.ts | 2 - src/locales/ru/queue.ts | 8 - src/locales/ru/radio.ts | 3 - src/locales/ru/sidebar.ts | 18 - src/locales/ru/smartPlaylists.ts | 1 - src/locales/zh/entityRating.ts | 2 - src/locales/zh/orbit.ts | 1 - src/locales/zh/player.ts | 12 - src/locales/zh/playlists.ts | 2 - src/locales/zh/queue.ts | 8 - src/locales/zh/radio.ts | 3 - src/locales/zh/sidebar.ts | 18 - src/locales/zh/smartPlaylists.ts | 1 - src/store/authMusicLibraryActions.test.ts | 31 - src/store/authMusicLibraryActions.ts | 112 +-- src/store/authServerProfileActions.ts | 83 +- src/store/authStore.persistence.test.ts | 14 - src/store/authStore.servers.test.ts | 57 -- src/store/authStore.ts | 2 - src/store/authStoreRehydrate.test.ts | 60 -- src/store/authStoreRehydrate.ts | 66 -- src/store/authStoreTypes.ts | 16 +- src/store/entityMutationBridge.ts | 13 - src/store/libraryIndexStore.test.ts | 45 - src/store/libraryIndexStore.ts | 27 +- .../playlistMembershipStore.source.test.ts | 14 - src/store/playlistMembershipStore.ts | 38 +- src/store/useBrowseLibraryScope.test.ts | 103 -- src/store/useBrowseLibraryScope.ts | 47 - src/store/useReachableLibrarySources.ts | 22 - src/styles/components/index.css | 1 - src/styles/components/modal.css | 14 +- .../components/orbit-session-top-strip.css | 6 +- .../components/playback-alternative-modal.css | 47 - src/styles/components/playlist-edit-modal.css | 4 +- src/styles/components/playlists-page.css | 22 +- .../components/radio-browser-directory.css | 10 +- .../components/radio-card-edit-chip.css | 9 +- src/styles/components/ui-scale-slider.css | 3 +- src/styles/layout/sidebar.css | 194 +--- .../serverSwitchOrbitQueue.scenario.test.ts | 17 +- src/utils/server/switchActiveServer.ts | 30 - 421 files changed, 2030 insertions(+), 12081 deletions(-) delete mode 100644 src-tauri/crates/psysonic-library/src/scope_statistics.rs delete mode 100644 src/app/musicLibraryCatalogReloadBridge.test.ts rename src/{lib/library => features/artist/hooks}/loadArtistDetailMultiScope.test.ts (87%) rename src/{lib/library => features/artist/hooks}/loadArtistDetailMultiScope.ts (91%) delete mode 100644 src/features/folderBrowser/utils/folderBrowserHelpers.source.test.ts delete mode 100644 src/features/home/store/homeFeedCache.test.ts delete mode 100644 src/features/home/utils/homeFeedLoader.test.ts delete mode 100644 src/features/home/utils/homeFeedLoader.ts delete mode 100644 src/features/orbit/components/OrbitStartTrigger.test.tsx delete mode 100644 src/features/playback/components/PlaybackAlternativeModal.test.tsx delete mode 100644 src/features/playback/components/PlaybackAlternativeModal.tsx delete mode 100644 src/features/playback/store/playbackAlternativeStore.test.ts delete mode 100644 src/features/playback/store/playbackAlternativeStore.ts delete mode 100644 src/features/playback/store/playbackErrorSkip.test.ts delete mode 100644 src/features/playback/store/playbackErrorSkip.ts delete mode 100644 src/features/playback/utils/mixRatingFilter.scope.test.ts delete mode 100644 src/features/playlist/components/PlaylistCard.multiServer.test.tsx delete mode 100644 src/features/playlist/hooks/usePendingSmartPolling.test.ts delete mode 100644 src/features/playlist/hooks/usePlaylistSongMutations.test.ts delete mode 100644 src/features/playlist/hooks/usePlaylistsLibraryScopeCounts.test.ts delete mode 100644 src/features/playlist/hooks/useSmartCoverCollage.test.ts delete mode 100644 src/features/playlist/utils/resolvePlaylistTargetTrackIds.test.ts delete mode 100644 src/features/playlist/utils/resolvePlaylistTargetTrackIds.ts delete mode 100644 src/features/playlist/utils/runPlaylistCsvImport.test.ts delete mode 100644 src/features/playlist/utils/runPlaylistsOpenSmartEditor.test.ts delete mode 100644 src/features/playlist/utils/updatePlaylistMembership.test.ts delete mode 100644 src/features/playlist/utils/updatePlaylistMembership.ts delete mode 100644 src/features/queue/components/QueueServerSliceDialog.test.tsx delete mode 100644 src/features/queue/components/QueueServerSliceDialog.tsx delete mode 100644 src/features/queue/components/queueServerSliceChoice.test.tsx delete mode 100644 src/features/queue/utils/queueServerSlices.ts delete mode 100644 src/features/radio/components/RadioCard.test.tsx delete mode 100644 src/features/radio/components/RadioDirectoryModal.test.tsx delete mode 100644 src/features/radio/components/RadioEditModal.test.tsx delete mode 100644 src/features/radio/utils/radioSourceLabels.test.ts delete mode 100644 src/features/radio/utils/radioStationIdentity.test.ts delete mode 100644 src/features/radio/utils/radioStationIdentity.ts delete mode 100644 src/features/search/hooks/useSongBrowseList.multiScope.test.ts delete mode 100644 src/lib/api/subsonicRadio.test.ts delete mode 100644 src/lib/hooks/useModalFocus.ts delete mode 100644 src/lib/hooks/useRangeSelection.test.ts delete mode 100644 src/lib/library/hooks/useLibraryIndexSync.test.ts delete mode 100644 src/lib/library/libraryBrowseScope.test.ts delete mode 100644 src/lib/library/libraryBrowseScope.ts delete mode 100644 src/lib/library/libraryEntityKey.source.test.ts delete mode 100644 src/lib/library/libraryEntityKey.test.ts delete mode 100644 src/lib/library/libraryEntityKey.ts delete mode 100644 src/lib/library/librarySession.owner.test.ts delete mode 100644 src/lib/library/randomScopeReads.ts delete mode 100644 src/lib/media/entityOverrideKey.ts delete mode 100644 src/lib/network/libraryServerReachability.ts delete mode 100644 src/lib/server/serverIndexOwner.test.ts delete mode 100644 src/store/entityMutationBridge.ts delete mode 100644 src/store/libraryIndexStore.test.ts delete mode 100644 src/store/playlistMembershipStore.source.test.ts delete mode 100644 src/store/useBrowseLibraryScope.test.ts delete mode 100644 src/store/useBrowseLibraryScope.ts delete mode 100644 src/store/useReachableLibrarySources.ts delete mode 100644 src/styles/components/playback-alternative-modal.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f43f3a7..65b40b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,14 +139,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view. -### Multi-server Library — one ordered catalog across your servers - -**By [@cucadmuh](https://github.com/cucadmuh), PR [#1309](https://github.com/Psychotoxical/psysonic/pull/1309)** - -* Select and prioritise multiple servers from the sidebar Library picker. Albums, artists, tracks, search, Home, genres, lossless, random mixes, composers, statistics and detail pages merge their ready online indexes into one de-duplicated catalog; unavailable sources are clearly excluded without hiding downloaded music in **Offline**. -* Playback, playlists, radio and folders keep their real source ownership. Failed streams offer an explicit **Play from server** choice instead of silently switching accounts, mixed-server queue sharing asks which source to export, and playlist/radio edits always target the owning server. -* Favorites and ratings fan out durably to matching copies on selected servers, including offline retry and deferred sync while an index is still being built. Play counts and scrobbles remain attached only to the concrete source that actually played. - ## Changed diff --git a/src-tauri/crates/psysonic-library/Cargo.toml b/src-tauri/crates/psysonic-library/Cargo.toml index 8ffec2a2..2cb45aed 100644 --- a/src-tauri/crates/psysonic-library/Cargo.toml +++ b/src-tauri/crates/psysonic-library/Cargo.toml @@ -11,7 +11,7 @@ psysonic-core = { path = "../psysonic-core" } psysonic-integration = { path = "../psysonic-integration" } tauri = { version = "2" } -specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } +specta = { version = "=2.0.0-rc.25", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.40", features = ["bundled"] } diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index c37d7e6a..aefe40d8 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -194,24 +194,14 @@ pub fn run_advanced_search( &req.server_id, req.library_scope.as_deref(), req.library_scopes.as_deref(), - )?; + ); // Any >1-library scope dedups album/artist rows via cluster keys, including // the Layer-1 same-server path — build keys first so dedup works on a cold // index (idempotent; only rebuilds when needed). - let pair_reader_required = multi_library_merge_enabled(&scope_pairs) - || scope_pairs - .first() - .is_some_and(|pair| pair.server_id != req.server_id); - if pair_reader_required { - for server_id in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::>() { - crate::identity::ensure_cluster_keys_built(store, server_id)?; - } + if multi_library_merge_enabled(&scope_pairs) { + crate::identity::ensure_cluster_keys_built(store, &req.server_id)?; } - if scoped_layer1_eligible(&scope_pairs) - && scope_pairs - .first() - .is_some_and(|pair| pair.server_id == req.server_id) - { + if scoped_layer1_eligible(&scope_pairs) { return run_advanced_search_layer1_scope( store, req, @@ -223,7 +213,7 @@ pub fn run_advanced_search( skip_totals, ); } - if pair_reader_required { + if multi_library_merge_enabled(&scope_pairs) { return run_advanced_search_multi_scope( store, req, @@ -239,7 +229,7 @@ pub fn run_advanced_search( let mut legacy = req.clone(); if legacy.library_scope.is_none() { if let Some(pair) = scope_pairs.first() { - legacy.library_scope = pair.library_id.clone(); + legacy.library_scope = Some(pair.library_id.clone()); } } @@ -1150,12 +1140,11 @@ fn push_artist_library_scope_pairs( pairs: &[LibraryScopePair], applied: &mut BTreeSet, ) { - if pairs.iter().any(|p| p.library_id.is_none()) { - return; - } + // Pairs may carry profile or index `server_id`; this query is already pinned to + // one server via `ar.server_id = ?`, so only drop empty library ids. let scoped: Vec<&LibraryScopePair> = pairs .iter() - .filter(|p| p.server_id == _server_id) + .filter(|p| !p.library_id.trim().is_empty()) .collect(); if scoped.is_empty() { return; @@ -1166,7 +1155,7 @@ fn push_artist_library_scope_pairs( let clause = library_scope_sargable_equals_sql("t"); w.push_params( &format!("{exists_prefix}{clause})"), - vec![SqlValue::Text(scoped[0].library_id.clone().unwrap_or_default())], + vec![SqlValue::Text(scoped[0].library_id.clone())], ); } else { let in_clause = library_scope_in_sql("t", scoped.len()); @@ -1174,7 +1163,7 @@ fn push_artist_library_scope_pairs( &format!("{exists_prefix}{in_clause})"), scoped .iter() - .map(|p| SqlValue::Text(p.library_id.clone().unwrap_or_default())) + .map(|p| SqlValue::Text(p.library_id.clone())) .collect(), ); } @@ -1186,7 +1175,7 @@ fn push_artist_library_scope(w: &mut WhereBuilder, req: &LibraryAdvancedSearchRe &req.server_id, req.library_scope.as_deref(), req.library_scopes.as_deref(), - ).unwrap_or_default(); + ); push_artist_library_scope_pairs(w, &req.server_id, &pairs, applied); } @@ -2125,7 +2114,6 @@ struct AlbumOrderCols { name: &'static str, artist: String, year: &'static str, - synced: &'static str, } impl AlbumOrderCols { @@ -2136,7 +2124,6 @@ impl AlbumOrderCols { name: "MAX(t.album) COLLATE NOCASE", artist: sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)"), year: "MAX(t.year)", - synced: "MAX(t.synced_at)", } } @@ -2146,7 +2133,6 @@ impl AlbumOrderCols { name: "album COLLATE NOCASE", artist: sql_display_artist_from("artist", "album_artist"), year: "year", - synced: "synced_at", } } } @@ -2158,7 +2144,6 @@ fn album_order_sql(sort: &[LibrarySortClause], cols: &AlbumOrderCols) -> Option< "name" => cols.name.to_string(), "artist" => format!("{} COLLATE NOCASE", cols.artist), "year" => cols.year.to_string(), - "synced" => cols.synced.to_string(), "random" => "RANDOM()".to_string(), _ => continue, }; @@ -2194,7 +2179,6 @@ pub(crate) fn sort_column(field: &str, entity: EntityKind) -> Option<&'static st ("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"), ("year", EntityKind::Album) => Some("a.year"), ("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"), - ("synced", EntityKind::Album) => Some("a.synced_at"), ("name", EntityKind::Artist) => Some("COALESCE(ar.name_sort, ar.name) COLLATE NOCASE"), // SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample, // no index scan needed beyond the row-id range. Direction is ignored. @@ -3611,7 +3595,7 @@ mod tests { fn scope_pair(server: &str, lib: &str) -> crate::dto::LibraryScopePair { crate::dto::LibraryScopePair { server_id: server.into(), - library_id: Some(lib.into()), + library_id: lib.into(), } } @@ -4098,27 +4082,4 @@ mod tests { assert_eq!(legacy_resp.albums, scoped_resp.albums); assert_eq!(legacy_resp.totals, scoped_resp.totals); } - - #[test] - fn single_exact_pair_on_another_server_uses_pair_server() { - let store = LibraryStore::open_in_memory(); - TrackRepository::new(&store) - .upsert_batch(&[ - scoped_track( - "s1", "fallback", "Song", "Artist", "Fallback", "al1", "lib", - None, None, None, - ), - scoped_track( - "s2", "selected", "Song", "Artist", "Selected", "al2", "lib", - None, None, None, - ), - ]) - .unwrap(); - let mut request = req("s1", &[EntityKind::Track]); - request.library_scopes = Some(vec![scope_pair("s2", "lib")]); - let response = run_advanced_search(&store, &request).unwrap(); - assert_eq!(response.tracks.len(), 1); - assert_eq!(response.tracks[0].id, "selected"); - assert_eq!(response.tracks[0].server_id, "s2"); - } } diff --git a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs index 7b1bf7d6..bd6ea39e 100644 --- a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs +++ b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs @@ -2,8 +2,7 @@ use crate::dto::{ LibraryAlbumDto, LibraryArtistLosslessBrowseRequest, LibraryArtistLosslessBrowseResponse, - LibraryScopeArtistDetailRequest, LibraryTrackDto, multi_library_merge_enabled, - ordered_library_scope_pairs, + LibraryTrackDto, }; use crate::lossless_formats::track_is_lossless_sql; use crate::search::{ @@ -40,61 +39,9 @@ pub fn get_artist_lossless_browse( store: &LibraryStore, req: &LibraryArtistLosslessBrowseRequest, ) -> Result { - let scope_pairs = ordered_library_scope_pairs( - &req.server_id, - req.library_scope.as_deref(), - req.library_scopes.as_deref(), - )?; - if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? { + if !crate::dto::track_index_nonempty(store, &req.server_id)? { return Ok(empty_response()); } - let use_pair_reader = scope_pairs.len() > 1 - || scope_pairs.first().is_some_and(|pair| { - pair.server_id != req.server_id || pair.library_id.is_none() - }); - if use_pair_reader { - if multi_library_merge_enabled(&scope_pairs) { - for server_id in scope_pairs - .iter() - .map(|p| p.server_id.as_str()) - .collect::>() - { - crate::identity::ensure_cluster_keys_built(store, server_id)?; - } - } - let detail = crate::scope_merge::artist_detail( - store, - &LibraryScopeArtistDetailRequest { - scopes: scope_pairs, - artist_id: req.artist_id.clone(), - server_id: req.server_id.clone(), - }, - )?; - let tracks: Vec<_> = detail - .tracks - .into_iter() - .filter(|track| { - track - .suffix - .as_deref() - .is_some_and(|suffix| crate::lossless_formats::LOSSLESS_SUFFIXES.contains(&suffix.to_ascii_lowercase().as_str())) - }) - .collect(); - let album_ids: std::collections::HashSet<(&str, &str)> = tracks - .iter() - .filter_map(|track| track.album_id.as_deref().map(|id| (track.server_id.as_str(), id))) - .collect(); - let albums = detail - .albums - .into_iter() - .filter(|album| album_ids.contains(&(album.server_id.as_str(), album.id.as_str()))) - .collect(); - return Ok(LibraryArtistLosslessBrowseResponse { - albums, - tracks, - source: "local".to_string(), - }); - } let lossless_sql = track_is_lossless_sql("t"); let mut track_where = vec![ @@ -108,11 +55,8 @@ pub fn get_artist_lossless_browse( SqlValue::Text(req.artist_id.clone()), ]; - let scope_ids = scope_pairs - .first() - .and_then(|pair| pair.library_id.clone()) - .map(|library_id| vec![library_id]) - .unwrap_or_else(|| combined_scope_library_ids(req.library_scope.as_deref(), None)); + let scope_ids = + combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref()); push_library_scope_filter(&mut track_where, &mut track_params, &scope_ids); let track_where_sql = track_where.join(" AND "); diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs index 6a5f6b8d..094e4991 100644 --- a/src-tauri/crates/psysonic-library/src/browse_support.rs +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -7,7 +7,6 @@ use tauri::State; use crate::dto::CatalogYearBoundsDto; use crate::dto::GenreAlbumCountDto; use crate::dto::LibraryAlbumDto; -use crate::dto::LibraryScopePair; use crate::runtime::LibraryRuntime; use crate::store::LibraryStore; use crate::sync::mapping::format_iso_ms_z; @@ -258,39 +257,6 @@ pub fn library_get_catalog_year_bounds( result } -pub(crate) fn catalog_year_bounds_for_scopes( - store: &LibraryStore, - scopes: &[LibraryScopePair], -) -> Result { - let scopes = crate::scope_merge::normalize_scope_pairs(scopes)?; - if scopes.is_empty() { - return Ok(CatalogYearBoundsDto { min_year: None, max_year: None }); - } - let (cte, binds) = crate::scope_merge::scope_cte_sql(&scopes); - let sql = format!( - "{cte} SELECT MIN(t.year), MAX(t.year) \ - FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \ - WHERE t.deleted = 0 AND t.year IS NOT NULL AND t.year > 0" - ); - store.with_read_conn(|conn| { - conn.query_row(&sql, rusqlite::params_from_iter(binds.iter()), |row| { - Ok(CatalogYearBoundsDto { - min_year: row.get::<_, Option>(0)?.map(|year| year as i32), - max_year: row.get::<_, Option>(1)?.map(|year| year as i32), - }) - }) - }).map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub fn library_scope_catalog_year_bounds( - runtime: State<'_, LibraryRuntime>, - scopes: Vec, -) -> Result { - catalog_year_bounds_for_scopes(&runtime.store, &scopes) -} - pub(crate) fn genre_album_counts_for_server( store: &LibraryStore, server_id: &str, @@ -338,55 +304,6 @@ pub(crate) fn genre_album_counts_for_server( .map_err(|e| e.to_string()) } -pub(crate) fn genre_album_counts_for_scopes( - store: &LibraryStore, - scopes: &[LibraryScopePair], -) -> Result, String> { - let scopes = crate::scope_merge::normalize_scope_pairs(scopes)?; - if scopes.is_empty() { - return Ok(Vec::new()); - } - if crate::dto::multi_library_merge_enabled(&scopes) { - for server_id in scopes - .iter() - .map(|pair| pair.server_id.as_str()) - .collect::>() - { - crate::identity::ensure_cluster_keys_built(store, server_id)?; - } - } - let (cte, binds) = crate::scope_merge::scope_cte_sql(&scopes); - let album_key = crate::scope_merge::ALBUM_DEDUP_KEY; - let track_key = crate::scope_merge::TRACK_DEDUP_KEY; - let sql = format!( - "{cte}, genre_rows AS ( \ - SELECT tg.genre, {album_key} AS album_dedup, {track_key} AS track_dedup \ - FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \ - INNER JOIN track_genre tg ON tg.server_id = t.server_id AND tg.track_id = t.id \ - LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.deleted = 0 AND tg.album_id IS NOT NULL AND tg.album_id != '' \ - ) \ - SELECT genre, COUNT(DISTINCT album_dedup), COUNT(DISTINCT track_dedup) \ - FROM genre_rows GROUP BY genre COLLATE NOCASE HAVING COUNT(DISTINCT album_dedup) > 0 \ - ORDER BY 2 DESC, genre COLLATE NOCASE ASC" - ); - store - .with_read_conn(|conn| { - let mut stmt = conn.prepare(&sql)?; - let rows = stmt - .query_map(rusqlite::params_from_iter(binds.iter()), |r| { - Ok(GenreAlbumCountDto { - value: r.get(0)?, - album_count: r.get::<_, i64>(1)?.max(0) as u32, - song_count: r.get::<_, i64>(2)?.max(0) as u32, - }) - })? - .collect::>>()?; - Ok(rows) - }) - .map_err(|e| e.to_string()) -} - /// Distinct album counts per track genre — same grouping as genre album browse. #[tauri::command] #[specta::specta] @@ -394,21 +311,19 @@ pub fn library_get_genre_album_counts( runtime: State<'_, LibraryRuntime>, server_id: String, library_scope: Option, - library_scopes: Option>, + library_scopes: Option>, ) -> Result, String> { let trace = psysonic_core::logging::should_log_albums_browse_trace(); - let scopes = crate::dto::ordered_library_scope_pairs( - &server_id, - library_scope.as_deref(), - library_scopes.as_deref(), - )?; + let scopes = if let Some(scopes) = library_scopes { + normalized_library_scopes(&scopes) + } else if let Some(scope) = library_scope.as_deref().filter(|s| !s.trim().is_empty()) { + vec![scope.to_string()] + } else { + vec![] + }; let trace_scopes = scopes.clone(); let t0 = std::time::Instant::now(); - let result = if scopes.is_empty() { - genre_album_counts_for_server(&runtime.store, &server_id, &[]) - } else { - genre_album_counts_for_scopes(&runtime.store, &scopes) - }; + let result = genre_album_counts_for_server(&runtime.store, &server_id, &scopes); if trace { let step_ms = t0.elapsed().as_millis(); let genre_count = result.as_ref().map(|rows| rows.len()).unwrap_or(0); @@ -439,8 +354,7 @@ mod tests { use crate::store::LibraryStore; use super::{ - apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_scopes, - genre_album_counts_for_server, catalog_year_bounds_for_scopes, + apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_server, overlay_album_level_starred_at, reconcile_album_stars, StarredAlbumReconcileItem, }; use crate::dto::LibraryAlbumDto; @@ -538,27 +452,6 @@ mod tests { assert!(!raw.contains("starred")); } - #[test] - fn catalog_year_bounds_respect_cross_server_scope() { - let store = LibraryStore::open_in_memory(); - let mut old = make_row("s1", "t1", "al1", 1); - old.library_id = Some("a".into()); - old.year = Some(1990); - let mut recent = make_row("s2", "t2", "al2", 1); - recent.library_id = Some("b".into()); - recent.year = Some(2024); - let mut excluded = make_row("s2", "t3", "al3", 1); - excluded.library_id = Some("other".into()); - excluded.year = Some(2030); - TrackRepository::new(&store).upsert_batch(&[old, recent, excluded]).unwrap(); - let bounds = catalog_year_bounds_for_scopes(&store, &[ - crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) }, - crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: Some("b".into()) }, - ]).unwrap(); - assert_eq!(bounds.min_year, Some(1990)); - assert_eq!(bounds.max_year, Some(2024)); - } - #[test] fn apply_album_patch_clears_stale_starred_in_raw_json() { let store = Arc::new(LibraryStore::open_in_memory()); @@ -737,34 +630,6 @@ mod tests { assert_eq!(counts[1].song_count, 1); } - #[test] - fn genre_album_counts_include_cross_server_whole_sources_and_empty_library_rows() { - let store = LibraryStore::open_in_memory(); - let mut first = make_row("s1", "t1", "al1", 1); - first.library_id = Some("lib-a".into()); - first.genre = Some("Rock".into()); - first.title = "Shared Track".into(); - first.album = "Shared Album".into(); - let mut second = make_row("s2", "t2", "al2", 1); - second.library_id = Some(String::new()); - second.genre = Some("Rock".into()); - second.title = "Shared Track".into(); - second.album = "Shared Album".into(); - TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap(); - - let counts = genre_album_counts_for_scopes( - &store, - &[ - crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: None }, - crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: None }, - ], - ) - .unwrap(); - let rock = counts.iter().find(|row| row.value == "Rock").unwrap(); - assert_eq!(rock.album_count, 1); - assert_eq!(rock.song_count, 1); - } - #[test] fn genre_album_counts_respect_library_scope() { let store = Arc::new(LibraryStore::open_in_memory()); diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index e3324ef5..34e7bbaa 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -23,7 +23,6 @@ use crate::dto::{ count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, - LibraryEntitySourceDto, LibraryResolveEntitySourcesRequest, LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest, LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest, LibraryTrackDto, @@ -687,16 +686,6 @@ pub fn library_cluster_rebuild( crate::identity::rebuild_cluster_keys(&runtime.store, server_id) } -#[tauri::command] -#[specta::specta] -pub async fn library_resolve_entity_sources( - runtime: State<'_, LibraryRuntime>, - request: LibraryResolveEntitySourcesRequest, -) -> Result, String> { - let store = Arc::clone(&runtime.store); - library_spawn_blocking(move || scope_merge::resolve_entity_sources(&store, &request)).await -} - // NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!. #[tauri::command] pub async fn library_scope_list_albums( @@ -747,36 +736,6 @@ pub async fn library_scope_artist_detail( library_spawn_blocking(move || scope_merge::artist_detail(&store, &request)).await } -#[tauri::command] -#[specta::specta] -pub async fn library_scope_catalog_statistics( - runtime: State<'_, LibraryRuntime>, - request: crate::dto::LibraryScopeCatalogStatisticsRequest, -) -> Result { - let store = Arc::clone(&runtime.store); - library_spawn_blocking(move || crate::scope_statistics::catalog_statistics(&store, &request)).await -} - -// NOT specta-collected: nested album DTO carries `raw_json: Value`. -#[tauri::command] -pub async fn library_scope_most_played_albums( - runtime: State<'_, LibraryRuntime>, - request: crate::dto::LibraryScopeMostPlayedRequest, -) -> Result, String> { - let store = Arc::clone(&runtime.store); - library_spawn_blocking(move || crate::scope_statistics::most_played_albums(&store, &request)).await -} - -// NOT specta-collected: artist DTO carries `raw_json: Value`. -#[tauri::command] -pub async fn library_scope_list_artists_by_role( - runtime: State<'_, LibraryRuntime>, - request: crate::dto::LibraryScopeArtistRoleRequest, -) -> Result, String> { - let store = Arc::clone(&runtime.store); - library_spawn_blocking(move || crate::scope_statistics::artists_by_role(&store, &request)).await -} - // NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!. #[tauri::command] pub async fn library_get_artist_lossless_browse( diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index ad7c5bd6..640b59a0 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -391,59 +391,6 @@ pub struct CatalogYearBoundsDto { pub max_year: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LibraryScopeCatalogStatisticsRequest { - pub scopes: Vec, - #[serde(default)] - pub format_sample_limit: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LibraryScopeFormatCountDto { - pub format: String, - pub count: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LibraryScopeCatalogStatisticsDto { - pub artist_count: u32, - pub album_count: u32, - pub track_count: u32, - pub duration_sec: i64, - pub genres: Vec, - pub formats: Vec, - pub format_sample_size: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct LibraryScopeMostPlayedRequest { - pub scopes: Vec, - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub offset: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct LibraryScopeMostPlayedAlbumDto { - pub album: LibraryAlbumDto, - pub play_count: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LibraryScopeArtistRoleRequest { - pub scopes: Vec, - pub role: String, - #[serde(default)] - pub limit: Option, -} - /// Per-genre album/track totals from the local track catalog (Genres cloud + browse). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] #[serde(rename_all = "camelCase")] @@ -695,10 +642,10 @@ pub struct LibraryLosslessAlbumsRequest { pub server_id: String, #[serde(default)] pub library_scope: Option, - /// Ordered server/library sources; takes precedence over the legacy single - /// `library_scope` when present. `library_id: None` means the whole server. + /// Ordered library ids for a multi-library selection; takes precedence over + /// the legacy single `library_scope` when present. #[serde(default)] - pub library_scopes: Option>, + pub library_scopes: Option>, #[serde(default = "default_lossless_limit")] pub limit: u32, #[serde(default)] @@ -726,10 +673,10 @@ pub struct LibraryArtistLosslessBrowseRequest { pub artist_id: String, #[serde(default)] pub library_scope: Option, - /// Ordered server/library sources; takes precedence over the legacy single - /// `library_scope` when present. `library_id: None` means the whole server. + /// Ordered library ids for a multi-library selection; takes precedence over + /// the legacy single `library_scope` when present. #[serde(default)] - pub library_scopes: Option>, + pub library_scopes: Option>, } /// Lossless albums + tracks for one artist (local index). @@ -746,73 +693,37 @@ pub struct LibraryArtistLosslessBrowseResponse { // ────────────────────────────────────────────────────────────────────── /// One `(server_id, library_id)` pair in priority order (index 0 = highest). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct LibraryScopePair { pub server_id: String, - /// `None` means every indexed library on this server. `Some("")` is the - /// concrete implicit library id and must not be treated as whole-server. - pub library_id: Option, -} - -/// Entity kind accepted by `library_resolve_entity_sources`. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub enum LibrarySourceEntityType { - Track, - Album, - Artist, -} - -/// Resolve one concrete browse entity to every matching concrete source in an -/// explicitly ordered scope. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LibraryResolveEntitySourcesRequest { - pub entity_type: LibrarySourceEntityType, - pub anchor_server_id: String, - pub anchor_id: String, - pub scopes: Vec, -} - -/// Concrete source metadata for one browse identity partition. Identity keys -/// remain internal so the frontend contract cannot persist raw cluster hashes. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LibraryEntitySourceDto { - pub server_id: String, - pub id: String, pub library_id: String, - pub priority: u32, - pub duration_sec: Option, - pub suffix: Option, - pub bit_rate: Option, - pub size_bytes: Option, - pub starred_at: Option, - pub user_rating: Option, } /// Derive ordered `(server_id, library_id)` pairs from request fields. -/// List order is merge priority (index 0 wins). Duplicate pairs keep their first -/// occurrence; mixing whole-server and exact-library sources for one server is rejected. +/// List order is merge priority (index 0 wins). Empty = all libraries on the server. pub(crate) fn ordered_library_scope_pairs( server_id: &str, library_scope: Option<&str>, library_scopes: Option<&[LibraryScopePair]>, -) -> Result, String> { +) -> Vec { if let Some(scopes) = library_scopes { - let pairs = crate::scope_merge::normalize_scope_pairs(scopes)?; + let pairs: Vec = scopes + .iter() + .filter(|p| !p.server_id.trim().is_empty() && !p.library_id.trim().is_empty()) + .cloned() + .collect(); if !pairs.is_empty() { - return Ok(pairs); + return pairs; } } if let Some(scope) = library_scope.map(str::trim).filter(|s| !s.is_empty()) { - return Ok(vec![LibraryScopePair { + return vec![LibraryScopePair { server_id: server_id.to_string(), - library_id: Some(scope.to_string()), - }]); + library_id: scope.to_string(), + }]; } - Ok(Vec::new()) + Vec::new() } /// Layer-2 dedup runs only when the ordered scope has more than one pair. @@ -825,7 +736,9 @@ pub(crate) fn scoped_layer1_eligible(scopes: &[LibraryScopePair]) -> bool { let Some(first) = scopes.first() else { return false; }; - scopes.iter().all(|p| p.server_id == first.server_id) + scopes + .iter() + .all(|p| p.server_id == first.server_id && !p.library_id.trim().is_empty()) } /// Paginated album/artist browse over an ordered multi-library scope. diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs index cd139597..4fe93a82 100644 --- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -88,6 +88,15 @@ pub fn list_albums_by_genre( store: &LibraryStore, req: &LibraryGenreAlbumsRequest, ) -> Result { + if !crate::dto::track_index_nonempty(store, &req.server_id)? { + return Ok(LibraryGenreAlbumsResponse { + albums: Vec::new(), + has_more: false, + total: None, + source: "local".to_string(), + }); + } + let genre = req.genre.trim(); if genre.is_empty() { return Ok(LibraryGenreAlbumsResponse { @@ -105,23 +114,13 @@ pub fn list_albums_by_genre( &req.server_id, req.library_scope.as_deref(), req.library_scopes.as_deref(), - )?; - if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? { - return Ok(LibraryGenreAlbumsResponse { - albums: Vec::new(), - has_more: false, - total: None, - source: "local".to_string(), - }); - } + ); // Any >1-library scope collapses duplicates via cluster keys — including the // Layer-1 same-server path, whose genre `EXISTS` sets `merge_by_album_key`. // Build keys first so dedup works on a cold index (not only after a prior // search / sync-idle rebuild happened to populate them). if multi_library_merge_enabled(&scope_pairs) { - for server_id in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::>() { - crate::identity::ensure_cluster_keys_built(store, server_id)?; - } + crate::identity::ensure_cluster_keys_built(store, &req.server_id)?; } if scoped_layer1_eligible(&scope_pairs) { return list_albums_by_genre_layer1_scope(store, req, &scope_pairs, genre, limit, offset); @@ -133,7 +132,7 @@ pub fn list_albums_by_genre( let mut legacy = req.clone(); if legacy.library_scope.is_none() { if let Some(pair) = scope_pairs.first() { - legacy.library_scope = pair.library_id.clone(); + legacy.library_scope = Some(pair.library_id.clone()); } } @@ -368,40 +367,6 @@ mod tests { } } - #[test] - fn cross_server_whole_scope_genre_browse_merges_duplicate_albums() { - let store = LibraryStore::open_in_memory(); - let mut first = track("s1", "t1", "al1", "Rock"); - first.library_id = Some("lib-a".into()); - first.album = "Shared Album".into(); - let mut second = track("s2", "t2", "al2", "Rock"); - second.library_id = Some(String::new()); - second.album = "Shared Album".into(); - TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap(); - crate::identity::rebuild_cluster_keys(&store, None).unwrap(); - - let response = list_albums_by_genre( - &store, - &LibraryGenreAlbumsRequest { - server_id: "s1".into(), - genre: "Rock".into(), - library_scope: None, - library_scopes: Some(vec![ - LibraryScopePair { server_id: "s2".into(), library_id: None }, - LibraryScopePair { server_id: "s1".into(), library_id: None }, - ]), - sort: vec![], - limit: 50, - offset: 0, - include_total: true, - }, - ) - .unwrap(); - assert_eq!(response.albums.len(), 1); - assert_eq!(response.albums[0].server_id, "s2"); - assert_eq!(response.total, Some(1)); - } - #[test] fn list_albums_by_genre_respects_library_scope_and_total() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/identity/mod.rs b/src-tauri/crates/psysonic-library/src/identity/mod.rs index bd4659ab..9e4ae6a5 100644 --- a/src-tauri/crates/psysonic-library/src/identity/mod.rs +++ b/src-tauri/crates/psysonic-library/src/identity/mod.rs @@ -11,7 +11,6 @@ pub use attach::{ remove_cluster_files_for_library, CLUSTER_DB_FILENAME, CLUSTER_SCHEMA, }; pub use norm::NORM_VERSION; -pub(crate) use norm::norm_part; pub use rebuild::{cluster_rebuild_needed, ensure_cluster_keys_built, rebuild_cluster_keys}; pub use keys::{build_track_cluster_keys, TrackClusterKeys}; diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index cd7d8a71..4096529c 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -39,7 +39,6 @@ pub mod payload; pub mod repos; pub mod runtime; pub mod scope_merge; -pub mod scope_statistics; pub mod search; pub mod store; pub mod sync; diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs index 5276deda..cf3b4a69 100644 --- a/src-tauri/crates/psysonic-library/src/live_search.rs +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -46,15 +46,9 @@ pub fn run_live_search( }); } - let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes)?; - let pair_reader_required = multi_library_merge_enabled(&scope_pairs) - || scope_pairs - .first() - .is_some_and(|pair| pair.server_id != server_id); - if pair_reader_required { - for scope_server in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::>() { - crate::identity::ensure_cluster_keys_built(store, scope_server)?; - } + let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes); + if multi_library_merge_enabled(&scope_pairs) { + crate::identity::ensure_cluster_keys_built(store, server_id)?; return run_live_search_multi_scope( store, &scope_pairs, @@ -69,7 +63,7 @@ pub fn run_live_search( .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string) - .or_else(|| scope_pairs.first().and_then(|p| p.library_id.clone())); + .or_else(|| scope_pairs.first().map(|p| p.library_id.clone())); store.with_read_conn(|conn| { let scopes = scopes_from_option(effective_scope.as_deref()); @@ -875,11 +869,11 @@ mod tests { let scopes = vec![ LibraryScopePair { server_id: "s1".into(), - library_id: Some("lib-a".into()), + library_id: "lib-a".into(), }, LibraryScopePair { server_id: "s1".into(), - library_id: Some("lib-b".into()), + library_id: "lib-b".into(), }, ]; let resp = run_live_search( @@ -901,50 +895,6 @@ mod tests { assert_eq!(resp.tracks[0].id, "t-a"); } - #[test] - fn cross_server_whole_scope_live_search_keeps_priority_and_empty_library_rows() { - use crate::dto::LibraryScopePair; - use crate::identity::rebuild_cluster_keys; - - let store = LibraryStore::open_in_memory(); - let mut first = track("s1", "t-a", "Shared Song", "Shared Artist", "Shared Album", "al-a", "ar-a"); - first.library_id = Some("lib-a".into()); - let mut second = track("s2", "t-b", "Shared Song", "Shared Artist", "Shared Album", "al-b", "ar-b"); - second.library_id = Some(String::new()); - TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap(); - rebuild_cluster_keys(&store, None).unwrap(); - - let scopes = vec![ - LibraryScopePair { server_id: "s2".into(), library_id: None }, - LibraryScopePair { server_id: "s1".into(), library_id: None }, - ]; - let resp = run_live_search(&store, "s1", "shared", None, Some(&scopes), 5, 5, 10).unwrap(); - assert_eq!(resp.tracks.len(), 1); - assert_eq!(resp.tracks[0].server_id, "s2"); - assert_eq!(resp.tracks[0].id, "t-b"); - } - - #[test] - fn single_exact_pair_on_another_server_does_not_use_fallback_server() { - use crate::dto::LibraryScopePair; - - let store = LibraryStore::open_in_memory(); - let mut fallback = track("s1", "fallback", "Shared Song", "Artist", "Album", "al1", "ar1"); - fallback.library_id = Some("lib".into()); - let mut selected = track("s2", "selected", "Shared Song", "Artist", "Album", "al2", "ar2"); - selected.library_id = Some("lib".into()); - TrackRepository::new(&store).upsert_batch(&[fallback, selected]).unwrap(); - - let scopes = vec![LibraryScopePair { - server_id: "s2".into(), - library_id: Some("lib".into()), - }]; - let response = run_live_search(&store, "s1", "shared", None, Some(&scopes), 5, 5, 10).unwrap(); - assert_eq!(response.tracks.len(), 1); - assert_eq!(response.tracks[0].id, "selected"); - assert_eq!(response.tracks[0].server_id, "s2"); - } - /// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture` #[test] #[ignore] diff --git a/src-tauri/crates/psysonic-library/src/lossless_albums.rs b/src-tauri/crates/psysonic-library/src/lossless_albums.rs index 25494983..06a00043 100644 --- a/src-tauri/crates/psysonic-library/src/lossless_albums.rs +++ b/src-tauri/crates/psysonic-library/src/lossless_albums.rs @@ -2,10 +2,7 @@ //! //! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`. -use crate::dto::{ - LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, - multi_library_merge_enabled, ordered_library_scope_pairs, -}; +use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse}; use crate::lossless_formats::track_is_lossless_sql; use crate::search::{combined_scope_library_ids, library_scope_in_sql, library_scope_sargable_equals_sql}; use crate::store::LibraryStore; @@ -41,47 +38,13 @@ pub fn list_lossless_albums( store: &LibraryStore, req: &LibraryLosslessAlbumsRequest, ) -> Result { + if !crate::dto::track_index_nonempty(store, &req.server_id)? { + return Ok(empty_response()); + } + let limit = req.limit.max(1); let offset = req.offset; let lossless_sql = track_is_lossless_sql("t"); - let scope_pairs = ordered_library_scope_pairs( - &req.server_id, - req.library_scope.as_deref(), - req.library_scopes.as_deref(), - )?; - if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? { - return Ok(empty_response()); - } - let use_pair_reader = scope_pairs.len() > 1 - || scope_pairs.first().is_some_and(|pair| { - pair.server_id != req.server_id || pair.library_id.is_none() - }); - if use_pair_reader { - if multi_library_merge_enabled(&scope_pairs) { - for server_id in scope_pairs - .iter() - .map(|p| p.server_id.as_str()) - .collect::>() - { - crate::identity::ensure_cluster_keys_built(store, server_id)?; - } - } - let (albums, _) = crate::scope_merge::list_albums_filtered( - store, - &scope_pairs, - &lossless_sql, - &[], - "ORDER BY album COLLATE NOCASE ASC, album_id ASC", - limit, - offset, - true, - )?; - return Ok(LibraryLosslessAlbumsResponse { - has_more: albums.len() as u32 == limit, - albums, - source: "local".to_string(), - }); - } let mut where_clauses = vec![ "t.deleted = 0".to_string(), @@ -91,11 +54,8 @@ pub fn list_lossless_albums( ]; let mut params: Vec = vec![SqlValue::Text(req.server_id.clone())]; - let scope_ids = scope_pairs - .first() - .and_then(|pair| pair.library_id.clone()) - .map(|library_id| vec![library_id]) - .unwrap_or_else(|| combined_scope_library_ids(req.library_scope.as_deref(), None)); + let scope_ids = + combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref()); push_library_scope_filter(&mut where_clauses, &mut params, &scope_ids); let where_sql = where_clauses.join(" AND "); @@ -344,16 +304,7 @@ mod tests { .unwrap(); let mut scoped = req("s1", 50, 0); - scoped.library_scopes = Some(vec![ - crate::dto::LibraryScopePair { - server_id: "s1".into(), - library_id: Some("lib1".into()), - }, - crate::dto::LibraryScopePair { - server_id: "s1".into(), - library_id: Some("lib2".into()), - }, - ]); + scoped.library_scopes = Some(vec!["lib1".into(), "lib2".into()]); let resp = list_lossless_albums(&store, &scoped).unwrap(); let mut ids: Vec<_> = resp.albums.iter().map(|a| a.id.clone()).collect(); ids.sort(); @@ -379,24 +330,4 @@ mod tests { assert_eq!(page2.albums.len(), 1); assert!(!page2.has_more); } - - #[test] - fn cross_server_whole_scope_lossless_browse_uses_priority_owner() { - let store = LibraryStore::open_in_memory(); - let mut first = track_with_suffix("s1", "t1", "al1", "Shared", "flac", 16); - first.library_id = Some("lib-a".into()); - let mut second = track_with_suffix("s2", "t2", "al2", "Shared", "flac", 24); - second.library_id = Some(String::new()); - TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap(); - crate::identity::rebuild_cluster_keys(&store, None).unwrap(); - - let mut request = req("s1", 50, 0); - request.library_scopes = Some(vec![ - crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: None }, - crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: None }, - ]); - let response = list_lossless_albums(&store, &request).unwrap(); - assert_eq!(response.albums.len(), 1); - assert_eq!(response.albums[0].server_id, "s2"); - } } diff --git a/src-tauri/crates/psysonic-library/src/scope_merge.rs b/src-tauri/crates/psysonic-library/src/scope_merge.rs index e1d286e0..a412f416 100644 --- a/src-tauri/crates/psysonic-library/src/scope_merge.rs +++ b/src-tauri/crates/psysonic-library/src/scope_merge.rs @@ -5,17 +5,15 @@ use rusqlite::types::Value as SqlValue; use rusqlite::{params_from_iter, OptionalExtension}; use serde_json::Value; -use std::collections::{HashMap, HashSet}; use crate::album_compilation_filter::pick_album_group_artist; use crate::artist_sort::{sort_key_for_display_name, DEFAULT_IGNORED_ARTICLES}; use crate::browse_support::{overlay_album_starred_at_rows, read_album_starred_at}; use crate::dto::{ - LibraryAlbumDto, LibraryArtistDto, LibraryEntitySourceDto, - LibraryResolveEntitySourcesRequest, LibraryScopeAlbumDetailRequest, + LibraryAlbumDto, LibraryArtistDto, LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest, LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopePair, - LibraryScopeSearchRequest, LibrarySourceEntityType, LibraryTrackDto, + LibraryScopeSearchRequest, LibraryTrackDto, }; use crate::repos::row_to_track_row; use crate::search::{ @@ -24,7 +22,7 @@ use crate::search::{ use crate::store::LibraryStore; /// NULL `album_key` rows never merge — fall back to a per-server album id. -pub(crate) const ALBUM_DEDUP_KEY: &str = "CASE WHEN ck.album_key IS NOT NULL THEN ck.album_key \ +const ALBUM_DEDUP_KEY: &str = "CASE WHEN ck.album_key IS NOT NULL THEN ck.album_key \ ELSE ('null:' || t.server_id || ':' || COALESCE(NULLIF(t.album_id, ''), t.id)) END"; /// NULL `artist_key` rows never merge. @@ -37,7 +35,7 @@ const ARTIST_DEDUP_KEY: &str = "CASE WHEN ck.artist_key IS NOT NULL THEN ck.arti /// two up to ~4 s apart inside a bucket merge. Kept as a single GROUP BY key for /// speed; a true tolerance window would need a self-join. Encoder-padding drift at /// boundaries is the known trade-off. -pub(crate) const TRACK_DEDUP_KEY: &str = "CASE WHEN ck.cluster_key IS NOT NULL \ +const TRACK_DEDUP_KEY: &str = "CASE WHEN ck.cluster_key IS NOT NULL \ THEN ck.cluster_key || ':' || CAST((ck.duration_sec / 5) AS TEXT) \ ELSE ('null:' || t.server_id || ':' || t.id) END"; @@ -51,44 +49,10 @@ const ARTIST_PICK_KEY: &str = "printf('%08d|%s', pr, artist_id)"; const TRACK_FTS_BM25_RANK: &str = "bm25(track_fts, 10.0, 3.0, 5.0, 3.0, 0.0)"; -pub(crate) fn normalize_scope_pairs( - scopes: &[LibraryScopePair], -) -> Result, String> { - let mut normalized = Vec::with_capacity(scopes.len()); - let mut seen = HashSet::new(); - let mut server_modes: HashMap = HashMap::new(); - for pair in scopes { - let server_id = pair.server_id.trim(); - if server_id.is_empty() { - return Err("scope server_id must not be empty".into()); - } - let whole = pair.library_id.is_none(); - if let Some(previous_whole) = server_modes.insert(server_id.to_string(), whole) { - if previous_whole != whole { - return Err(format!( - "server {server_id} cannot mix whole-server and exact-library scopes" - )); - } - } - let normalized_pair = LibraryScopePair { - server_id: server_id.to_string(), - library_id: pair.library_id.clone(), - }; - if seen.insert((normalized_pair.server_id.clone(), normalized_pair.library_id.clone())) { - normalized.push(normalized_pair); - } - } - Ok(normalized) -} - fn non_empty_scopes(scopes: &[LibraryScopePair]) -> Result<&[LibraryScopePair], String> { if scopes.is_empty() { return Err("scopes must not be empty".into()); } - let normalized = normalize_scope_pairs(scopes)?; - if normalized.len() != scopes.len() { - return Err("duplicate scope pair".into()); - } Ok(scopes) } @@ -100,71 +64,26 @@ fn clamp_offset(offset: Option) -> u32 { offset.unwrap_or(0) } -/// Compile exact-library and whole-server sources into separate indexed branches. -/// `scoped_track` contains only rowids and pair priority so downstream readers keep -/// their existing `s.pr` / `t.*` shape without an OR predicate on `track`. +/// `WITH scope(server_id, library_id, pr) AS (VALUES …)` — filters and yields priority. pub(crate) fn scope_cte_sql(scopes: &[LibraryScopePair]) -> (String, Vec) { - let exact = scopes + let values = scopes .iter() .enumerate() - .filter(|(_, pair)| pair.library_id.is_some()) - .collect::>(); - let whole = scopes - .iter() - .enumerate() - .filter(|(_, pair)| pair.library_id.is_none()) - .collect::>(); - let exact_values = if exact.is_empty() { - "SELECT NULL, NULL, NULL WHERE 0".to_string() - } else { - format!( - "VALUES {}", - exact - .iter() - .map(|(i, _)| format!("(?, ?, {i})")) - .collect::>() - .join(", ") - ) - }; - let whole_values = if whole.is_empty() { - "SELECT NULL, NULL WHERE 0".to_string() - } else { - format!( - "VALUES {}", - whole - .iter() - .map(|(i, _)| format!("(?, {i})")) - .collect::>() - .join(", ") - ) - }; - let sql = format!( - "WITH exact_scope(server_id, library_id, pr) AS ({exact_values}), \ - whole_scope(server_id, pr) AS ({whole_values}), \ - scoped_track(rowid, pr) AS ( \ - SELECT t.rowid, s.pr FROM exact_scope s \ - CROSS JOIN track t ON t.server_id = s.server_id AND t.library_id = s.library_id \ - WHERE t.deleted = 0 \ - UNION ALL \ - SELECT t.rowid, s.pr FROM whole_scope s \ - CROSS JOIN track t ON t.server_id = s.server_id \ - WHERE t.deleted = 0 \ - )" - ); - let mut binds = Vec::with_capacity(exact.len() * 2 + whole.len()); - for (_, pair) in exact { - binds.push(SqlValue::Text(pair.server_id.clone())); - binds.push(SqlValue::Text(pair.library_id.clone().unwrap_or_default())); - } - for (_, pair) in whole { + .map(|(i, _)| format!("(?, ?, {i})")) + .collect::>() + .join(", "); + let sql = format!("WITH scope(server_id, library_id, pr) AS (VALUES {values})"); + let mut binds = Vec::with_capacity(scopes.len() * 2); + for pair in scopes { binds.push(SqlValue::Text(pair.server_id.clone())); + binds.push(SqlValue::Text(pair.library_id.clone())); } (sql, binds) } fn scoped_track_join_layer1() -> &'static str { - "FROM scoped_track s \ - CROSS JOIN track t ON t.rowid = s.rowid \ + "FROM scope s \ + CROSS JOIN track t ON t.server_id = s.server_id AND t.library_id = s.library_id \ WHERE t.deleted = 0" } @@ -174,8 +93,8 @@ fn scoped_track_join() -> &'static str { // via idx_track_library_* instead of scanning all tracks and probing scope. // This only visits tracks in the selected libraries, so a subset of libraries // is proportionally cheaper than the whole server. - "FROM scoped_track s \ - CROSS JOIN track t ON t.rowid = s.rowid \ + "FROM scope s \ + CROSS JOIN track t ON t.server_id = s.server_id AND t.library_id = s.library_id \ LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ WHERE t.deleted = 0" } @@ -286,8 +205,12 @@ fn album_row_to_dto(row: AlbumListRow) -> LibraryAlbumDto { /// `library_scope_list_albums` — dedup by `album_key`, priority winner metadata. /// -/// Track copies are reduced to their priority winner before album totals are computed, -/// so song count and duration describe the same deduped recording set. +/// Aggregated in a single `GROUP BY album_dedup` (no per-track window): `song_count` +/// is exact via `COUNT(DISTINCT track_dedup)`; `duration_total` is `SUM(duration_sec)`, +/// which double-counts a track only when the *same* recording is present in multiple +/// selected libraries. The album-list duration is not surfaced in the grid (detail and +/// now-playing recompute from the real track list), so this trade buys a ~2x browse +/// speedup on large multi-library scopes without a user-visible effect. /// Build cluster identity keys for every server in a >1-library scope before a /// browse that dedups via `cluster.track_cluster_key`. Without this the album/ /// artist dedup keys are uniformly NULL on a cold index (no prior search / sync @@ -316,7 +239,7 @@ fn finish_scope_album_list( Ok((albums, total)) } -pub(crate) fn ensure_cluster_keys_for_scopes( +fn ensure_cluster_keys_for_scopes( store: &LibraryStore, scopes: &[LibraryScopePair], ) -> Result<(), String> { @@ -367,25 +290,19 @@ pub fn list_albums( "{cte}, \ base AS ( \ SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ - t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.duration_sec, t.id, \ + t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.duration_sec, t.id, \ s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, {TRACK_DEDUP_KEY} AS track_dedup \ {scoped} AND t.album_id IS NOT NULL AND t.album_id != '' \ - ), \ - track_winners AS ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - year, genre, cover_art_id, starred_at, synced_at, duration_sec, id, pr, album_dedup, \ - MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _track_pick \ - FROM base GROUP BY album_dedup, track_dedup \ ) \ SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \ + song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \ FROM ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - year, genre, cover_art_id, starred_at, synced_at, \ - COUNT(*) AS song_count, SUM(duration_sec) AS duration_total, \ - MIN({ALBUM_PICK_KEY}) AS _pick \ - FROM track_winners GROUP BY album_dedup \ - ) \ + SELECT server_id, album_id, album, artist, artist_id, album_artist, \ + year, genre, cover_art_id, starred_at, synced_at, \ + COUNT(DISTINCT track_dedup) AS song_count, SUM(duration_sec) AS duration_total, \ + MIN({ALBUM_PICK_KEY}) AS _pick \ + FROM base GROUP BY album_dedup \ + ) \ {order} \ LIMIT ? OFFSET ?", scoped = scoped_track_join(), @@ -492,19 +409,17 @@ pub(crate) fn list_albums_layer1_filtered( let mut where_parts = vec![ "t.deleted = 0".to_string(), "t.server_id = ?".to_string(), + "t.library_id = ?".to_string(), "t.album_id IS NOT NULL AND t.album_id != ''".to_string(), ]; - if pair.library_id.is_some() { - where_parts.push("t.library_id = ?".to_string()); - } if !extra_where.trim().is_empty() { where_parts.push(extra_where.to_string()); } let where_sql = where_parts.join(" AND "); - let mut params = vec![SqlValue::Text(pair.server_id.clone())]; - if let Some(library_id) = &pair.library_id { - params.push(SqlValue::Text(library_id.clone())); - } + let mut params = vec![ + SqlValue::Text(pair.server_id.clone()), + SqlValue::Text(pair.library_id.clone()), + ]; params.extend_from_slice(extra_params); let count_sql = format!("SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}"); @@ -561,7 +476,7 @@ pub(crate) fn list_albums_layer1_filtered( ); let mut params = vec![SqlValue::Text(server_id.clone())]; for p in scopes { - params.push(SqlValue::Text(p.library_id.clone().unwrap_or_default())); + params.push(SqlValue::Text(p.library_id.clone())); } let count_sql = format!("SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}"); // Grouped shape — same reasoning as the single-scope branch above. @@ -621,26 +536,24 @@ pub(crate) fn list_albums_layer1_filtered( ), format!( "{cte}, \ - base AS ( \ - SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ - t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.duration_sec, t.id, \ - s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, {TRACK_DEDUP_KEY} AS track_dedup \ - {base_where} \ - ), track_winners AS ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, year, genre, \ - cover_art_id, starred_at, synced_at, duration_sec, id, pr, album_dedup, \ - MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _track_pick \ - FROM base GROUP BY album_dedup, track_dedup \ - ) \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \ - FROM ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - year, genre, cover_art_id, starred_at, synced_at, \ - COUNT(*) AS song_count, SUM(duration_sec) AS duration_total, \ - MIN({ALBUM_PICK_KEY}) AS _pick \ - FROM track_winners GROUP BY album_dedup \ - ) \ + per_lib AS ( \ + SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ + t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, \ + COUNT(*) AS song_count, SUM(t.duration_sec) AS duration_total, \ + s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, \ + MIN({ALBUM_PICK_KEY}) AS _pick \ + {base_where} \ + GROUP BY album_dedup, t.server_id, t.album_id, s.pr \ + ) \ + SELECT server_id, album_id, album, artist, artist_id, album_artist, \ + song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \ + FROM ( \ + SELECT server_id, album_id, album, artist, artist_id, album_artist, \ + year, genre, cover_art_id, starred_at, synced_at, \ + SUM(song_count) AS song_count, SUM(duration_total) AS duration_total, \ + MIN(_pick) AS _pick \ + FROM per_lib GROUP BY album_dedup \ + ) \ {deduped_order_sql} \ LIMIT ? OFFSET ?" ), @@ -785,7 +698,8 @@ pub(crate) fn list_index_artists_layer1_filtered( ) -> Result<(Vec, u32), String> { let scopes = non_empty_scopes(scopes)?; let (cte, scope_binds) = scope_cte_sql(scopes); - let scoped_from = "FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid"; + let scoped_from = "FROM scope s \ + CROSS JOIN track t ON t.server_id = s.server_id AND t.library_id = s.library_id"; let credited_cte = if album_artists_only { // #1209: album credit = one row per album-level credit in scope, not every // track performer with a server-wide `album_count` index row. @@ -972,22 +886,16 @@ pub(crate) fn list_albums_filtered( t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.duration_sec, t.id, \ s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, {TRACK_DEDUP_KEY} AS track_dedup \ {base_where} \ - ), \ - track_winners AS ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - year, genre, cover_art_id, starred_at, synced_at, duration_sec, id, pr, album_dedup, \ - MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _track_pick \ - FROM base GROUP BY album_dedup, track_dedup \ ) \ SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \ + song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \ FROM ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, \ - year, genre, cover_art_id, starred_at, synced_at, \ - COUNT(*) AS song_count, SUM(duration_sec) AS duration_total, \ - MIN({ALBUM_PICK_KEY}) AS _pick \ - FROM track_winners GROUP BY album_dedup \ - ) \ + SELECT server_id, album_id, album, artist, artist_id, album_artist, \ + year, genre, cover_art_id, starred_at, synced_at, \ + COUNT(DISTINCT track_dedup) AS song_count, SUM(duration_sec) AS duration_total, \ + MIN({ALBUM_PICK_KEY}) AS _pick \ + FROM base GROUP BY album_dedup \ + ) \ {order_sql} \ LIMIT ? OFFSET ?", ); @@ -1160,8 +1068,9 @@ pub(crate) fn collect_scope_fts_rowids( SELECT f.rowid FROM track_fts f \ WHERE track_fts MATCH ? \ AND EXISTS ( \ - SELECT 1 FROM scoped_track sc \ - WHERE sc.rowid = f.rowid \ + SELECT 1 FROM track c \ + INNER JOIN scope sc ON c.server_id = sc.server_id AND c.library_id = sc.library_id \ + WHERE c.rowid = f.rowid AND c.deleted = 0 \ ) \ ORDER BY {TRACK_FTS_BM25_RANK} LIMIT ?", ); @@ -1326,9 +1235,9 @@ pub(crate) fn live_search_albums( FROM track_fts f \ WHERE track_fts MATCH ? \ AND EXISTS ( \ - SELECT 1 FROM scoped_track sc \ - INNER JOIN track c ON c.rowid = sc.rowid \ - WHERE c.rowid = f.rowid AND c.deleted = 0 \ + SELECT 1 FROM track c \ + INNER JOIN scope sc ON c.server_id = sc.server_id AND c.library_id = sc.library_id \ + WHERE c.rowid = f.rowid AND c.deleted = 0 \ AND c.album_id IS NOT NULL AND c.album_id != '' \ ) \ ORDER BY rank \ @@ -1340,7 +1249,7 @@ pub(crate) fn live_search_albums( MIN(h.rank) AS best_rank, {ALBUM_DEDUP_KEY} AS album_dedup \ FROM fts_hits h \ INNER JOIN track t ON t.rowid = h.rowid \ - INNER JOIN scoped_track s ON t.rowid = s.rowid \ + INNER JOIN scope s ON t.server_id = s.server_id AND t.library_id = s.library_id \ LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ WHERE t.deleted = 0 \ GROUP BY album_dedup, t.server_id, t.album_id, s.pr \ @@ -1405,9 +1314,9 @@ pub(crate) fn live_search_artists( FROM track_fts f \ WHERE track_fts MATCH ? \ AND EXISTS ( \ - SELECT 1 FROM scoped_track sc \ - INNER JOIN track c ON c.rowid = sc.rowid \ - WHERE c.rowid = f.rowid AND c.deleted = 0 \ + SELECT 1 FROM track c \ + INNER JOIN scope sc ON c.server_id = sc.server_id AND c.library_id = sc.library_id \ + WHERE c.rowid = f.rowid AND c.deleted = 0 \ AND c.artist_id IS NOT NULL AND c.artist_id != '' \ ) \ ORDER BY rank \ @@ -1418,7 +1327,7 @@ pub(crate) fn live_search_artists( MIN(h.rank) AS best_rank, {ARTIST_DEDUP_KEY} AS artist_dedup \ FROM fts_hits h \ INNER JOIN track t ON t.rowid = h.rowid \ - INNER JOIN scoped_track s ON t.rowid = s.rowid \ + INNER JOIN scope s ON t.server_id = s.server_id AND t.library_id = s.library_id \ LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ WHERE t.deleted = 0 \ GROUP BY t.server_id, t.artist_id, t.artist, t.synced_at, s.pr, artist_dedup \ @@ -1516,194 +1425,9 @@ fn lookup_artist_key( .map(Option::flatten) } -fn lookup_track_partition( - conn: &rusqlite::Connection, - server_id: &str, - track_id: &str, -) -> rusqlite::Result, i64)>> { - conn.query_row( - "SELECT ck.cluster_key, ck.duration_sec / 5 FROM track t \ - INNER JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.server_id = ? AND t.id = ? AND t.deleted = 0 LIMIT 1", - rusqlite::params![server_id, track_id], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .optional() -} - -fn map_entity_source_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { - let priority = r.get::<_, i64>(3)?; - Ok(LibraryEntitySourceDto { - server_id: r.get(0)?, - id: r.get(1)?, - library_id: r.get::<_, Option>(2)?.unwrap_or_default(), - priority: u32::try_from(priority).unwrap_or(u32::MAX), - duration_sec: r.get(4)?, - suffix: r.get(5)?, - bit_rate: r.get(6)?, - size_bytes: r.get(7)?, - starred_at: r.get(8)?, - user_rating: r.get(9)?, - }) -} - -fn fetch_track_sources( - conn: &rusqlite::Connection, - scopes: &[LibraryScopePair], - cluster_key: Option<&str>, - duration_bucket: i64, - anchor_server: &str, - anchor_id: &str, -) -> rusqlite::Result> { - let (cte, scope_binds) = scope_cte_sql(scopes); - let key_filter = if cluster_key.is_some() { - "ck.cluster_key = ? AND ck.duration_sec / 5 = ?" - } else { - "t.server_id = ? AND t.id = ? AND ck.cluster_key IS NULL" - }; - let sql = format!( - "{cte} SELECT t.server_id, t.id, t.library_id, s.pr, t.duration_sec, t.suffix, \ - t.bit_rate, t.size_bytes, t.starred_at, t.user_rating \ - {scoped} AND {key_filter} \ - ORDER BY s.pr ASC, t.id ASC", - scoped = scoped_track_join(), - ); - let mut binds = scope_binds; - if let Some(key) = cluster_key { - binds.push(SqlValue::Text(key.to_string())); - binds.push(SqlValue::Integer(duration_bucket)); - } else { - binds.push(SqlValue::Text(anchor_server.to_string())); - binds.push(SqlValue::Text(anchor_id.to_string())); - } - let mut stmt = conn.prepare(&sql)?; - let rows = stmt - .query_map(params_from_iter(binds.iter()), map_entity_source_row)? - .collect(); - rows -} - -fn fetch_grouped_entity_sources( - conn: &rusqlite::Connection, - scopes: &[LibraryScopePair], - entity_type: LibrarySourceEntityType, - identity_key: Option<&str>, - anchor_server: &str, - anchor_id: &str, -) -> rusqlite::Result> { - let (entity_column, cluster_column) = match entity_type { - LibrarySourceEntityType::Album => ("album_id", "album_key"), - LibrarySourceEntityType::Artist => ("artist_id", "artist_key"), - LibrarySourceEntityType::Track => unreachable!("track sources use fetch_track_sources"), - }; - let (cte, scope_binds) = scope_cte_sql(scopes); - let key_filter = if identity_key.is_some() { - format!("ck.{cluster_column} = ?") - } else { - format!( - "t.server_id = ? AND t.{entity_column} = ? AND ck.{cluster_column} IS NULL" - ) - }; - let (metadata_join, duration_column, starred_column) = match entity_type { - LibrarySourceEntityType::Album => ( - "LEFT JOIN album e ON e.server_id = candidates.server_id AND e.id = candidates.entity_id", - "e.duration_sec", - "e.starred_at", - ), - LibrarySourceEntityType::Artist => ("", "NULL", "NULL"), - LibrarySourceEntityType::Track => unreachable!(), - }; - let sql = format!( - "{cte}, candidates AS ( \ - SELECT t.server_id, t.{entity_column} AS entity_id, t.library_id, s.pr, \ - ROW_NUMBER() OVER ( \ - PARTITION BY t.server_id, t.{entity_column} \ - ORDER BY s.pr ASC, t.id ASC \ - ) AS rn \ - {scoped} AND t.{entity_column} IS NOT NULL AND t.{entity_column} != '' AND {key_filter} \ - ) \ - SELECT candidates.server_id, candidates.entity_id, candidates.library_id, candidates.pr, \ - {duration_column}, NULL, NULL, NULL, {starred_column}, NULL \ - FROM candidates {metadata_join} \ - WHERE candidates.rn = 1 ORDER BY candidates.pr ASC, candidates.entity_id ASC", - scoped = scoped_track_join(), - ); - let mut binds = scope_binds; - if let Some(key) = identity_key { - binds.push(SqlValue::Text(key.to_string())); - } else { - binds.push(SqlValue::Text(anchor_server.to_string())); - binds.push(SqlValue::Text(anchor_id.to_string())); - } - let mut stmt = conn.prepare(&sql)?; - let rows = stmt - .query_map(params_from_iter(binds.iter()), map_entity_source_row)? - .collect(); - rows -} - -/// Resolve a concrete anchor to all matching concrete rows in caller-supplied -/// pair priority. Track identity includes browse's fixed five-second bucket. -pub fn resolve_entity_sources( - store: &LibraryStore, - request: &LibraryResolveEntitySourcesRequest, -) -> Result, String> { - let scopes = non_empty_scopes(&request.scopes)?; - let anchor_server = request.anchor_server_id.trim(); - let anchor_id = request.anchor_id.trim(); - if anchor_server.is_empty() || anchor_id.is_empty() { - return Err("anchor_server_id and anchor_id are required".into()); - } - crate::identity::ensure_cluster_keys_built(store, anchor_server)?; - for pair in scopes { - if pair.server_id != anchor_server { - crate::identity::ensure_cluster_keys_built(store, &pair.server_id)?; - } - } - - store.with_read_conn(|conn| match request.entity_type { - LibrarySourceEntityType::Track => { - let Some((cluster_key, duration_bucket)) = - lookup_track_partition(conn, anchor_server, anchor_id)? - else { - return Ok(Vec::new()); - }; - fetch_track_sources( - conn, - scopes, - cluster_key.as_deref(), - duration_bucket, - anchor_server, - anchor_id, - ) - } - LibrarySourceEntityType::Album => { - let key = lookup_album_key(conn, anchor_server, anchor_id)?; - fetch_grouped_entity_sources( - conn, - scopes, - request.entity_type, - key.as_deref(), - anchor_server, - anchor_id, - ) - } - LibrarySourceEntityType::Artist => { - let key = lookup_artist_key(conn, anchor_server, anchor_id)?; - fetch_grouped_entity_sources( - conn, - scopes, - request.entity_type, - key.as_deref(), - anchor_server, - anchor_id, - ) - } - }) -} - -fn priority_album_owner(candidates: &[LibraryAlbumDto]) -> LibraryAlbumDto { - candidates.first().cloned().unwrap_or_else(|| LibraryAlbumDto { +/// Caller must pre-sort `candidates` by scope priority (lowest index first). +fn merge_album_by_priority(candidates: &[LibraryAlbumDto]) -> LibraryAlbumDto { + let mut out = candidates.first().cloned().unwrap_or_else(|| LibraryAlbumDto { server_id: String::new(), id: String::new(), name: String::new(), @@ -1717,11 +1441,47 @@ fn priority_album_owner(candidates: &[LibraryAlbumDto]) -> LibraryAlbumDto { starred_at: None, synced_at: 0, raw_json: Value::Null, - }) + }); + for c in candidates.iter().skip(1) { + merge_optional_text(&mut out.name, &c.name); + merge_optional(&mut out.artist, &c.artist); + merge_optional(&mut out.artist_id, &c.artist_id); + merge_optional(&mut out.genre, &c.genre); + merge_optional(&mut out.cover_art_id, &c.cover_art_id); + merge_optional_i64(&mut out.year, c.year); + merge_optional_i64(&mut out.starred_at, c.starred_at); + merge_optional_i64(&mut out.song_count, c.song_count); + merge_optional_i64(&mut out.duration_sec, c.duration_sec); + if out.synced_at < c.synced_at { + out.synced_at = c.synced_at; + } + } + out } -fn priority_artist_owner(candidates: &[LibraryArtistDto]) -> LibraryArtistDto { - candidates.first().cloned().unwrap_or_else(|| LibraryArtistDto { +fn merge_optional_text(dst: &mut String, src: &str) { + if dst.trim().is_empty() && !src.trim().is_empty() { + *dst = src.to_string(); + } +} + +fn merge_optional(dst: &mut Option, src: &Option) { + if dst.as_ref().is_none_or(|s| s.trim().is_empty()) { + if let Some(s) = src.as_ref().filter(|s| !s.trim().is_empty()) { + *dst = Some(s.clone()); + } + } +} + +fn merge_optional_i64(dst: &mut Option, src: Option) { + if dst.is_none() { + *dst = src; + } +} + +/// Caller must pre-sort `candidates` by scope priority (lowest index first). +fn merge_artist_by_priority(candidates: &[LibraryArtistDto]) -> LibraryArtistDto { + let mut out = candidates.first().cloned().unwrap_or_else(|| LibraryArtistDto { server_id: String::new(), id: String::new(), name: String::new(), @@ -1729,7 +1489,16 @@ fn priority_artist_owner(candidates: &[LibraryArtistDto]) -> LibraryArtistDto { album_count: None, synced_at: 0, raw_json: Value::Null, - }) + }); + for c in candidates.iter().skip(1) { + merge_optional_text(&mut out.name, &c.name); + merge_optional(&mut out.name_sort, &c.name_sort); + merge_optional_i64(&mut out.album_count, c.album_count); + if out.synced_at < c.synced_at { + out.synced_at = c.synced_at; + } + } + out } fn fetch_album_candidates( @@ -1854,9 +1623,16 @@ pub fn album_detail( store.with_read_conn(|conn| { let album_key = lookup_album_key(conn, server_id, album_id)?; let candidates = fetch_album_candidates(conn, scopes, album_key.as_deref(), server_id, album_id)?; - let albums: Vec = candidates.into_iter().map(|(_, a)| a).collect(); - let mut album = priority_album_owner(&albums); - album.starred_at = read_album_starred_at(conn, &album.server_id, &album.id).unwrap_or(None); + let mut albums: Vec = candidates.into_iter().map(|(_, a)| a).collect(); + albums.sort_by_key(|a| { + scopes + .iter() + .position(|p| p.server_id == a.server_id) + .unwrap_or(usize::MAX) as i64 + }); + let mut album = merge_album_by_priority(&albums); + album.starred_at = + read_album_starred_at(conn, server_id, album_id).unwrap_or(None); let tracks = fetch_scope_deduped_tracks_for_album_key( conn, scopes, @@ -1874,7 +1650,7 @@ fn fetch_artist_candidates( artist_key: Option<&str>, anchor_server: &str, anchor_artist_id: &str, -) -> rusqlite::Result> { +) -> rusqlite::Result> { let (cte, scope_binds) = scope_cte_sql(scopes); let key_filter = if artist_key.is_some() { "AND ck.artist_key = ?" @@ -1905,18 +1681,15 @@ fn fetch_artist_candidates( let rows = stmt .query_map(params_from_iter(binds.iter()), |r| { let name: String = r.get(2)?; - Ok(( - r.get(5)?, - LibraryArtistDto { - server_id: r.get(0)?, - id: r.get(1)?, - name: name.clone(), - name_sort: Some(sort_key_for_display_name(&name, DEFAULT_IGNORED_ARTICLES)), - album_count: Some(r.get(3)?), - synced_at: r.get(4)?, - raw_json: Value::Null, - }, - )) + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: name.clone(), + name_sort: Some(sort_key_for_display_name(&name, DEFAULT_IGNORED_ARTICLES)), + album_count: Some(r.get(3)?), + synced_at: r.get(4)?, + raw_json: Value::Null, + }) })? .collect::>>()?; Ok(rows) @@ -2035,15 +1808,20 @@ pub fn artist_detail( store.with_read_conn(|conn| { let artist_key = lookup_artist_key(conn, server_id, artist_id)?; - let candidates = fetch_artist_candidates( + let mut candidates = fetch_artist_candidates( conn, scopes, artist_key.as_deref(), server_id, artist_id, )?; - let candidates: Vec = candidates.into_iter().map(|(_, a)| a).collect(); - let artist = priority_artist_owner(&candidates); + candidates.sort_by_key(|a| { + scopes + .iter() + .position(|p| p.server_id == a.server_id) + .unwrap_or(usize::MAX) as i64 + }); + let artist = merge_artist_by_priority(&candidates); let albums = fetch_albums_for_artist_key( conn, scopes, @@ -2075,14 +1853,7 @@ mod tests { fn scope_pair(server: &str, lib: &str) -> LibraryScopePair { LibraryScopePair { server_id: server.into(), - library_id: Some(lib.into()), - } - } - - fn whole_scope(server: &str) -> LibraryScopePair { - LibraryScopePair { - server_id: server.into(), - library_id: None, + library_id: lib.into(), } } @@ -2192,8 +1963,6 @@ mod tests { assert_eq!(albums_a[0].id, "alb-a"); assert_eq!(albums_a[0].year, Some(2001)); assert_eq!(albums_a[0].genre.as_deref(), Some("Rock")); - assert_eq!(albums_a[0].song_count, Some(1)); - assert_eq!(albums_a[0].duration_sec, Some(200)); let req_b_first = LibraryScopeListRequest { scopes: vec![scope_pair("s1", "lib-b"), scope_pair("s1", "lib-a")], @@ -2390,7 +2159,7 @@ mod tests { } #[test] - fn album_detail_uses_one_priority_owner_record() { + fn album_detail_aggregates_metadata_by_priority() { let store = LibraryStore::open_in_memory(); seed_and_rebuild( &store, @@ -2435,379 +2204,11 @@ mod tests { ) .unwrap(); assert_eq!(detail.album.year, Some(2001)); - assert_eq!(detail.album.genre, None); - assert_eq!(detail.album.cover_art_id, None); + assert_eq!(detail.album.genre.as_deref(), Some("Jazz")); + assert_eq!(detail.album.cover_art_id.as_deref(), Some("cov-b")); assert_eq!(detail.tracks.len(), 1); } - #[test] - fn artist_detail_owner_uses_full_pair_priority_within_one_server() { - let store = LibraryStore::open_in_memory(); - seed_and_rebuild( - &store, - &[ - track( - "s1", "t-low", "One", Some("Shared Artist"), "Low", "al-low", - Some("artist-low"), 100, "lib-low", None, None, None, - ), - track( - "s1", "t-high", "Two", Some("Shared Artist"), "High", "al-high", - Some("artist-high"), 100, "lib-high", None, None, None, - ), - ], - ); - let detail = artist_detail( - &store, - &LibraryScopeArtistDetailRequest { - scopes: vec![scope_pair("s1", "lib-high"), scope_pair("s1", "lib-low")], - artist_id: "artist-low".into(), - server_id: "s1".into(), - }, - ) - .unwrap(); - assert_eq!(detail.artist.id, "artist-high"); - assert_eq!(detail.albums.len(), 2); - assert_eq!(detail.tracks.len(), 2); - } - - #[test] - fn scope_normalization_keeps_empty_library_and_rejects_overlap() { - let duplicate = vec![scope_pair("s1", ""), scope_pair("s1", "")]; - let normalized = normalize_scope_pairs(&duplicate).unwrap(); - assert_eq!(normalized, vec![scope_pair("s1", "")]); - - let overlap = vec![whole_scope("s1"), scope_pair("s1", "lib-a")]; - assert!(normalize_scope_pairs(&overlap) - .unwrap_err() - .contains("cannot mix whole-server and exact-library")); - } - - #[test] - fn whole_server_scope_includes_empty_library_but_exact_empty_does_not_include_others() { - let store = LibraryStore::open_in_memory(); - seed_and_rebuild( - &store, - &[ - track( - "s1", "t-empty", "Empty", Some("A"), "Empty Album", "al-empty", - Some("ar1"), 100, "", None, None, None, - ), - track( - "s1", "t-lib", "Named", Some("B"), "Named Album", "al-lib", - Some("ar2"), 100, "lib-a", None, None, None, - ), - ], - ); - let whole = list_albums( - &store, - &LibraryScopeListRequest { - scopes: vec![whole_scope("s1")], - sort: None, - limit: Some(10), - offset: None, - }, - ) - .unwrap(); - assert_eq!(whole.len(), 2); - - let exact_empty = list_albums( - &store, - &LibraryScopeListRequest { - scopes: vec![scope_pair("s1", "")], - sort: None, - limit: Some(10), - offset: None, - }, - ) - .unwrap(); - assert_eq!(exact_empty.len(), 1); - assert_eq!(exact_empty[0].id, "al-empty"); - } - - #[test] - fn cross_server_whole_scope_priority_and_duration_bucket_are_stable() { - let store = LibraryStore::open_in_memory(); - seed_and_rebuild( - &store, - &[ - track( - "s1", "t-a", "Shared", Some("Artist"), "Album", "al-a", - Some("ar-a"), 104, "lib-a", Some(2001), None, None, - ), - track( - "s2", "t-b", "Shared", Some("Artist"), "Album", "al-b", - Some("ar-b"), 104, "", Some(1999), None, None, - ), - track( - "s2", "t-boundary", "Shared", Some("Artist"), "Album", "al-b", - Some("ar-b"), 105, "", Some(1999), None, None, - ), - ], - ); - let first = search_tracks( - &store, - &LibraryScopeSearchRequest { - scopes: vec![whole_scope("s1"), whole_scope("s2")], - query: "Shared".into(), - limit: Some(10), - }, - ) - .unwrap(); - assert_eq!(first.len(), 2, "104s copies merge; 105s starts a new bucket"); - assert_eq!(first[0].server_id, "s1"); - - let flipped = search_tracks( - &store, - &LibraryScopeSearchRequest { - scopes: vec![whole_scope("s2"), whole_scope("s1")], - query: "Shared".into(), - limit: Some(10), - }, - ) - .unwrap(); - assert!(flipped.iter().any(|track| track.id == "t-b")); - assert!(!flipped.iter().any(|track| track.id == "t-a")); - } - - #[test] - fn source_resolver_track_matches_browse_partition_priority_and_metadata() { - let store = LibraryStore::open_in_memory(); - let mut high = track( - "s1", "t-high", "Shared", Some("Artist"), "Album", "al-high", - Some("ar-high"), 104, "lib-high", None, None, None, - ); - high.suffix = Some("flac".into()); - high.bit_rate = Some(1_000); - high.size_bytes = Some(30_000_000); - high.starred_at = Some(1_700_000_000); - high.user_rating = Some(5); - let mut low = track( - "s2", "t-low", "Shared", Some("Artist"), "Album", "al-low", - Some("ar-low"), 104, "lib-low", None, None, None, - ); - low.suffix = Some("mp3".into()); - low.bit_rate = Some(320); - low.size_bytes = Some(8_000_000); - let boundary = track( - "s3", "t-boundary", "Shared", Some("Artist"), "Album", "al-boundary", - Some("ar-boundary"), 105, "lib-boundary", None, None, None, - ); - seed_and_rebuild(&store, &[high, low, boundary]); - - let scopes = vec![ - scope_pair("s2", "lib-low"), - scope_pair("s1", "lib-high"), - scope_pair("s3", "lib-boundary"), - ]; - let sources = resolve_entity_sources( - &store, - &LibraryResolveEntitySourcesRequest { - entity_type: LibrarySourceEntityType::Track, - anchor_server_id: "s1".into(), - anchor_id: "t-high".into(), - scopes: scopes.clone(), - }, - ) - .unwrap(); - assert_eq!( - sources.iter().map(|source| source.id.as_str()).collect::>(), - vec!["t-low", "t-high"] - ); - assert_eq!(sources[0].priority, 0); - assert_eq!(sources[1].priority, 1); - assert_eq!(sources[1].library_id, "lib-high"); - assert_eq!(sources[1].duration_sec, Some(104)); - assert_eq!(sources[1].suffix.as_deref(), Some("flac")); - assert_eq!(sources[1].bit_rate, Some(1_000)); - assert_eq!(sources[1].size_bytes, Some(30_000_000)); - assert_eq!(sources[1].starred_at, Some(1_700_000_000)); - assert_eq!(sources[1].user_rating, Some(5)); - - let browse = search_tracks( - &store, - &LibraryScopeSearchRequest { - scopes, - query: "Shared".into(), - limit: Some(10), - }, - ) - .unwrap(); - assert_eq!(browse.len(), 2, "the 105-second boundary remains a separate partition"); - assert_eq!(browse[0].id, "t-low", "browse and resolver use pair priority"); - } - - #[test] - fn source_resolver_album_and_artist_use_browse_identity_and_pair_priority() { - let store = LibraryStore::open_in_memory(); - seed_and_rebuild( - &store, - &[ - track( - "s1", "t-a", "One", Some("Shared Artist"), "Shared Album", "al-a", - Some("ar-a"), 100, "lib-a", None, None, None, - ), - track( - "s2", "t-b", "Two", Some("Shared Artist"), "Shared Album", "al-b", - Some("ar-b"), 110, "lib-b", None, None, None, - ), - ], - ); - store - .with_conn_mut("test.source_resolver_album_metadata", |conn| { - conn.execute( - "INSERT INTO album(server_id, id, name, duration_sec, starred_at, synced_at, raw_json) \ - VALUES ('s1', 'al-a', 'Shared Album', 100, 11, 1, '{}'), \ - ('s2', 'al-b', 'Shared Album', 110, 22, 1, '{}')", - [], - )?; - Ok(()) - }) - .unwrap(); - let scopes = vec![scope_pair("s2", "lib-b"), scope_pair("s1", "lib-a")]; - - let albums = resolve_entity_sources( - &store, - &LibraryResolveEntitySourcesRequest { - entity_type: LibrarySourceEntityType::Album, - anchor_server_id: "s1".into(), - anchor_id: "al-a".into(), - scopes: scopes.clone(), - }, - ) - .unwrap(); - assert_eq!( - albums.iter().map(|source| source.id.as_str()).collect::>(), - vec!["al-b", "al-a"] - ); - assert_eq!(albums[0].priority, 0); - assert_eq!(albums[0].duration_sec, Some(110)); - assert_eq!(albums[0].starred_at, Some(22)); - assert_eq!(albums[0].suffix, None); - - let artists = resolve_entity_sources( - &store, - &LibraryResolveEntitySourcesRequest { - entity_type: LibrarySourceEntityType::Artist, - anchor_server_id: "s1".into(), - anchor_id: "ar-a".into(), - scopes, - }, - ) - .unwrap(); - assert_eq!( - artists.iter().map(|source| source.id.as_str()).collect::>(), - vec!["ar-b", "ar-a"] - ); - assert!(artists.iter().all(|source| source.duration_sec.is_none())); - assert!(artists.iter().all(|source| source.starred_at.is_none())); - } - - #[test] - fn source_resolver_returns_only_selected_concrete_sources_and_handles_missing_anchor() { - let store = LibraryStore::open_in_memory(); - seed_and_rebuild( - &store, - &[ - track( - "anchor", "t-anchor", "Shared", Some("Artist"), "Album", "al-anchor", - Some("ar-anchor"), 100, "lib-anchor", None, None, None, - ), - track( - "selected", "t-selected", "Shared", Some("Artist"), "Album", "al-selected", - Some("ar-selected"), 100, "", None, None, None, - ), - track( - "excluded", "t-excluded", "Shared", Some("Artist"), "Album", "al-excluded", - Some("ar-excluded"), 100, "lib-excluded", None, None, None, - ), - ], - ); - - let sources = resolve_entity_sources( - &store, - &LibraryResolveEntitySourcesRequest { - entity_type: LibrarySourceEntityType::Track, - anchor_server_id: "anchor".into(), - anchor_id: "t-anchor".into(), - scopes: vec![whole_scope("selected")], - }, - ) - .unwrap(); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].id, "t-selected"); - assert_eq!(sources[0].library_id, ""); - - let missing = resolve_entity_sources( - &store, - &LibraryResolveEntitySourcesRequest { - entity_type: LibrarySourceEntityType::Track, - anchor_server_id: "anchor".into(), - anchor_id: "missing".into(), - scopes: vec![whole_scope("selected")], - }, - ) - .unwrap(); - assert!(missing.is_empty()); - } - - #[test] - fn source_resolver_null_identity_does_not_merge_unrelated_entities() { - let store = LibraryStore::open_in_memory(); - seed_and_rebuild( - &store, - &[ - track( - "s1", "t-anchor", "No Artist", None, "Album", "al-anchor", None, 100, - "lib-a", None, None, None, - ), - track( - "s2", "t-other", "No Artist", None, "Album", "al-other", None, 100, - "lib-b", None, None, None, - ), - ], - ); - let sources = resolve_entity_sources( - &store, - &LibraryResolveEntitySourcesRequest { - entity_type: LibrarySourceEntityType::Track, - anchor_server_id: "s1".into(), - anchor_id: "t-anchor".into(), - scopes: vec![scope_pair("s1", "lib-a"), scope_pair("s2", "lib-b")], - }, - ) - .unwrap(); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].id, "t-anchor"); - } - - #[test] - fn scope_compiler_keeps_separate_indexed_branches_and_fts_exists() { - let scopes = vec![scope_pair("s1", "lib-a"), whole_scope("s2")]; - let (cte, _) = scope_cte_sql(&scopes); - assert!(cte.contains("exact_scope")); - assert!(cte.contains("whole_scope")); - assert!(cte.contains("UNION ALL")); - assert!(!cte.contains("IS NULL OR")); - - let store = LibraryStore::open_in_memory(); - let plan_sql = format!( - "EXPLAIN QUERY PLAN {cte} SELECT f.rowid FROM track_fts f \ - WHERE track_fts MATCH ? AND EXISTS (SELECT 1 FROM scoped_track sc WHERE sc.rowid = f.rowid) \ - ORDER BY {TRACK_FTS_BM25_RANK} LIMIT 10" - ); - let plan = store - .with_read_conn(|conn| { - let mut stmt = conn.prepare(&plan_sql)?; - let rows = stmt - .query_map(["s1", "lib-a", "s2", "shared"], |row| row.get::<_, String>(3))? - .collect::>>()?; - Ok(rows.join("\n")) - }) - .unwrap(); - assert!(plan.contains("VIRTUAL TABLE INDEX"), "{plan}"); - assert!(plan.contains("SEARCH t") || plan.contains("COVERING INDEX"), "{plan}"); - } - #[test] fn scope_list_album_star_uses_album_row_not_track_aggregate() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/scope_statistics.rs b/src-tauri/crates/psysonic-library/src/scope_statistics.rs deleted file mode 100644 index 391258d6..00000000 --- a/src-tauri/crates/psysonic-library/src/scope_statistics.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Scope-aware catalog aggregates and play-session identity views. - -use rusqlite::types::Value as SqlValue; -use rusqlite::params_from_iter; -use serde_json::Value; - -use crate::album_compilation_filter::pick_album_group_artist; -use crate::browse_support::overlay_album_starred_at_rows; -use crate::dto::{ - LibraryAlbumDto, LibraryScopeCatalogStatisticsDto, - LibraryScopeCatalogStatisticsRequest, LibraryScopeFormatCountDto, - LibraryScopeMostPlayedAlbumDto, LibraryScopeMostPlayedRequest, - LibraryArtistDto, LibraryScopeArtistRoleRequest, -}; -use crate::identity::norm_part; -use crate::scope_merge::{ - ensure_cluster_keys_for_scopes, normalize_scope_pairs, scope_cte_sql, ALBUM_DEDUP_KEY, - TRACK_DEDUP_KEY, -}; -use crate::store::LibraryStore; - -const ARTIST_DEDUP_KEY: &str = "CASE WHEN ck.artist_key IS NOT NULL THEN ck.artist_key \ - ELSE ('null:' || t.server_id || ':' || COALESCE(NULLIF(t.artist_id, ''), t.id)) END"; - -fn validated_scopes( - scopes: &[crate::dto::LibraryScopePair], -) -> Result, String> { - let scopes = normalize_scope_pairs(scopes)?; - if scopes.is_empty() { - return Err("scopes must not be empty".into()); - } - Ok(scopes) -} - -pub fn catalog_statistics( - store: &LibraryStore, - request: &LibraryScopeCatalogStatisticsRequest, -) -> Result { - let scopes = validated_scopes(&request.scopes)?; - ensure_cluster_keys_for_scopes(store, &scopes)?; - let sample_limit = request.format_sample_limit.unwrap_or(500).clamp(1, 5_000); - let (cte, binds) = scope_cte_sql(&scopes); - let sql = format!( - "{cte}, base AS ( \ - SELECT t.rowid, t.server_id, t.id, t.duration_sec, t.suffix, t.album_id, t.artist_id, \ - s.pr, {TRACK_DEDUP_KEY} AS track_dedup, {ALBUM_DEDUP_KEY} AS album_dedup, \ - {ARTIST_DEDUP_KEY} AS artist_dedup \ - FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \ - LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.deleted = 0 \ - ), winners AS ( \ - SELECT rowid, server_id, id, duration_sec, suffix, album_id, artist_id, \ - album_dedup, artist_dedup, MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _pick \ - FROM base GROUP BY track_dedup \ - ) \ - SELECT COUNT(*), COALESCE(SUM(duration_sec), 0), \ - COUNT(DISTINCT CASE WHEN album_id IS NOT NULL AND album_id != '' THEN album_dedup END), \ - COUNT(DISTINCT CASE WHEN artist_id IS NOT NULL AND artist_id != '' THEN artist_dedup END) \ - FROM winners" - ); - let (track_count, duration_sec, album_count, artist_count) = store - .with_read_conn(|conn| { - conn.query_row(&sql, params_from_iter(binds.iter()), |row| { - Ok(( - row.get::<_, i64>(0)?.max(0) as u32, - row.get::<_, i64>(1)?.max(0), - row.get::<_, i64>(2)?.max(0) as u32, - row.get::<_, i64>(3)?.max(0) as u32, - )) - }) - }) - .map_err(|e| e.to_string())?; - - let genres = crate::browse_support::genre_album_counts_for_scopes(store, &scopes)?; - let format_sql = format!( - "{cte}, base AS ( \ - SELECT t.server_id, t.id, t.suffix, s.pr, {TRACK_DEDUP_KEY} AS track_dedup \ - FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \ - LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.deleted = 0 \ - ), winners AS ( \ - SELECT suffix, MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _pick \ - FROM base GROUP BY track_dedup ORDER BY RANDOM() LIMIT ? \ - ) \ - SELECT COALESCE(NULLIF(UPPER(TRIM(suffix)), ''), 'UNKNOWN'), COUNT(*) \ - FROM winners GROUP BY 1 ORDER BY 2 DESC, 1 ASC" - ); - let mut format_binds = binds; - format_binds.push(SqlValue::Integer(i64::from(sample_limit))); - let formats = store - .with_read_conn(|conn| { - let mut stmt = conn.prepare(&format_sql)?; - let rows = stmt.query_map(params_from_iter(format_binds.iter()), |row| { - Ok(LibraryScopeFormatCountDto { - format: row.get(0)?, - count: row.get::<_, i64>(1)?.max(0) as u32, - }) - })?.collect::>>()?; - Ok(rows) - }) - .map_err(|e| e.to_string())?; - let format_sample_size = formats.iter().map(|row| row.count).sum(); - - Ok(LibraryScopeCatalogStatisticsDto { - artist_count, - album_count, - track_count, - duration_sec, - genres, - formats, - format_sample_size, - }) -} - -pub fn most_played_albums( - store: &LibraryStore, - request: &LibraryScopeMostPlayedRequest, -) -> Result, String> { - let scopes = validated_scopes(&request.scopes)?; - ensure_cluster_keys_for_scopes(store, &scopes)?; - let limit = request.limit.unwrap_or(50).clamp(1, 500); - let offset = request.offset.unwrap_or(0); - let (cte, mut binds) = scope_cte_sql(&scopes); - let sql = format!( - "{cte}, base AS ( \ - SELECT t.server_id, t.id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \ - t.year, t.genre, t.cover_art_id, t.synced_at, t.duration_sec, s.pr, \ - {ALBUM_DEDUP_KEY} AS album_dedup, {TRACK_DEDUP_KEY} AS track_dedup \ - FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \ - LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \ - ), track_winners AS ( \ - SELECT server_id, id, album_id, album, artist, artist_id, album_artist, year, genre, \ - cover_art_id, synced_at, duration_sec, pr, album_dedup, track_dedup, \ - MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _track_pick \ - FROM base GROUP BY album_dedup, track_dedup \ - ), album_totals AS ( \ - SELECT album_dedup, COUNT(*) AS song_count, SUM(duration_sec) AS duration_total \ - FROM track_winners GROUP BY album_dedup \ - ), played AS ( \ - SELECT b.*, ps.id AS session_id \ - FROM base b INNER JOIN play_session ps ON ps.server_id = b.server_id AND ps.track_id = b.id \ - ), played_albums AS ( \ - SELECT server_id, album_id, album, artist, artist_id, album_artist, year, genre, cover_art_id, \ - synced_at, album_dedup, COUNT(session_id) AS play_count, \ - MIN(printf('%08d|%s|%s', pr, server_id, album_id)) AS _pick \ - FROM played GROUP BY album_dedup \ - ) \ - SELECT p.server_id, p.album_id, p.album, p.artist, p.artist_id, p.album_artist, \ - a.song_count, a.duration_total, p.year, p.genre, p.cover_art_id, p.synced_at, \ - p.play_count, \ - p._pick \ - FROM played_albums p INNER JOIN album_totals a ON a.album_dedup = p.album_dedup \ - ORDER BY p.play_count DESC, p.album COLLATE NOCASE ASC, p.album_id ASC LIMIT ? OFFSET ?" - ); - binds.push(SqlValue::Integer(i64::from(limit))); - binds.push(SqlValue::Integer(i64::from(offset))); - let mut rows = store - .with_read_conn(|conn| { - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(params_from_iter(binds.iter()), |row| { - let track_artist: Option = row.get(3)?; - let album_artist: Option = row.get(5)?; - Ok(LibraryScopeMostPlayedAlbumDto { - album: LibraryAlbumDto { - server_id: row.get(0)?, - id: row.get(1)?, - name: row.get(2)?, - artist: pick_album_group_artist(track_artist, album_artist), - artist_id: row.get(4)?, - song_count: Some(row.get(6)?), - duration_sec: Some(row.get(7)?), - year: row.get(8)?, - genre: row.get(9)?, - cover_art_id: row.get(10)?, - starred_at: None, - synced_at: row.get(11)?, - raw_json: Value::Null, - }, - play_count: row.get::<_, i64>(12)?.max(0) as u32, - }) - })?.collect::>>()?; - Ok(rows) - }) - .map_err(|e| e.to_string())?; - store - .with_read_conn(|conn| { - let mut albums = rows.iter_mut().map(|row| &mut row.album).collect::>(); - for album in albums.iter_mut() { - let one = std::slice::from_mut(&mut **album); - overlay_album_starred_at_rows(conn, one); - } - Ok(()) - }) - .map_err(|e| e.to_string())?; - Ok(rows) -} - -pub fn artists_by_role( - store: &LibraryStore, - request: &LibraryScopeArtistRoleRequest, -) -> Result, String> { - let scopes = validated_scopes(&request.scopes)?; - ensure_cluster_keys_for_scopes(store, &scopes)?; - let role = request.role.trim().to_ascii_lowercase(); - if role.is_empty() { - return Err("role must not be empty".into()); - } - let limit = request.limit.unwrap_or(10_000).clamp(1, 20_000); - let (cte, mut binds) = scope_cte_sql(&scopes); - let rows_sql = format!( - "{cte}, role_rows AS ( \ - SELECT t.server_id, json_extract(j.value, '$.artist.id') AS artist_id, \ - json_extract(j.value, '$.artist.name') AS artist_name, \ - COALESCE(ck.album_key, 'null:' || t.server_id || ':' || COALESCE(t.album_id, t.id)) AS album_dedup, \ - t.synced_at, s.pr, \ - MIN(printf('%08d|%s|%s', s.pr, t.server_id, json_extract(j.value, '$.artist.id'))) AS _pick \ - FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \ - JOIN json_each(CASE WHEN json_valid(t.raw_json) THEN t.raw_json ELSE '{{}}' END, '$.contributors') j \ - LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \ - WHERE t.deleted = 0 AND LOWER(COALESCE(json_extract(j.value, '$.role'), '')) = ? \ - AND json_extract(j.value, '$.artist.id') IS NOT NULL \ - GROUP BY t.server_id, artist_id, artist_name, album_dedup, t.synced_at, s.pr \ - ) \ - SELECT server_id, artist_id, artist_name, album_dedup, synced_at, pr \ - FROM role_rows ORDER BY pr ASC, server_id ASC, artist_id ASC" - ); - binds.push(SqlValue::Text(role)); - store.with_read_conn(|conn| { - let mut stmt = conn.prepare(&rows_sql)?; - let rows = stmt.query_map(params_from_iter(binds.iter()), |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, i64>(4)?, - )) - })?.collect::>>()?; - let mut grouped: std::collections::HashMap)> = - std::collections::HashMap::new(); - for (server_id, artist_id, name, album_dedup, synced_at) in rows { - let key = norm_part(&name) - .unwrap_or_else(|| format!("null:{server_id}:{artist_id}")); - let entry = grouped.entry(key).or_insert_with(|| { - (LibraryArtistDto { - server_id, - id: artist_id, - name: name.clone(), - name_sort: Some(crate::artist_sort::sort_key_for_display_name( - &name, - crate::artist_sort::DEFAULT_IGNORED_ARTICLES, - )), - album_count: Some(0), - synced_at, - raw_json: Value::Null, - }, std::collections::HashSet::new()) - }); - if let Some(album_dedup) = album_dedup { - entry.1.insert(album_dedup); - } - } - let mut artists = grouped.into_values().map(|(mut artist, album_ids)| { - artist.album_count = Some(album_ids.len() as i64); - artist - }).collect::>(); - artists.sort_by(|a, b| a.name_sort.cmp(&b.name_sort).then_with(|| a.id.cmp(&b.id))); - artists.truncate(limit as usize); - Ok(artists) - }).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use crate::dto::{ - LibraryScopeArtistRoleRequest, LibraryScopeCatalogStatisticsRequest, - LibraryScopeMostPlayedRequest, LibraryScopePair, - }; - use crate::repos::{PlaySessionRepository, TrackRepository, TrackRow}; - use crate::store::LibraryStore; - - use super::{artists_by_role, catalog_statistics, most_played_albums}; - - fn row(server: &str, id: &str, album: &str, library: &str, duration: i64) -> TrackRow { - TrackRow { - server_id: server.into(), id: id.into(), title: "Song".into(), title_sort: None, - artist: Some("Artist".into()), artist_id: Some("artist".into()), album: album.into(), - album_id: Some(album.into()), album_artist: Some("Artist".into()), duration_sec: duration, - track_number: Some(1), disc_number: Some(1), year: Some(2024), genre: Some("Rock".into()), - suffix: Some("flac".into()), bit_rate: None, size_bytes: None, cover_art_id: Some(album.into()), - starred_at: None, user_rating: None, play_count: None, played_at: None, server_path: None, - library_id: Some(library.into()), isrc: Some("same-isrc".into()), mbid_recording: None, - bpm: None, replay_gain_track_db: None, replay_gain_album_db: None, replay_gain_peak: None, - content_hash: None, server_updated_at: None, server_created_at: None, deleted: false, - synced_at: 1, raw_json: "{}".into(), - } - } - - fn contributor_row( - server: &str, - id: &str, - album: &str, - library: &str, - contributors: serde_json::Value, - ) -> TrackRow { - let mut track = row(server, id, album, library, 200); - track.raw_json = serde_json::json!({ "contributors": contributors }).to_string(); - track - } - - fn scopes() -> Vec { - vec![ - LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) }, - LibraryScopePair { server_id: "s2".into(), library_id: Some("b".into()) }, - ] - } - - #[test] - fn catalog_totals_dedup_shared_recording_and_album_by_priority() { - let store = LibraryStore::open_in_memory(); - TrackRepository::new(&store).upsert_batch(&[ - row("s1", "t1", "shared-album", "a", 200), - row("s2", "t2", "shared-album", "b", 201), - ]).unwrap(); - let stats = catalog_statistics(&store, &LibraryScopeCatalogStatisticsRequest { - scopes: scopes(), format_sample_limit: Some(500), - }).unwrap(); - assert_eq!(stats.track_count, 1); - assert_eq!(stats.album_count, 1); - assert_eq!(stats.artist_count, 1); - assert_eq!(stats.duration_sec, 200); - assert_eq!(stats.format_sample_size, 1); - } - - #[test] - fn most_played_counts_concrete_sessions_once_but_displays_priority_identity() { - let store = LibraryStore::open_in_memory(); - TrackRepository::new(&store).upsert_batch(&[ - row("s1", "t1", "shared-album", "a", 200), - row("s2", "t2", "shared-album", "b", 201), - ]).unwrap(); - let repo = PlaySessionRepository::new(&store); - for (server_id, track_id, started_at_ms) in [("s1", "t1", 1000), ("s2", "t2", 2000)] { - repo.insert(&crate::dto::PlaySessionInputDto { - server_id: server_id.into(), track_id: track_id.into(), started_at_ms, - listened_sec: 30.0, position_max_sec: 30.0, end_reason: "next".into(), - duration_sec_hint: None, - }).unwrap(); - } - let rows = most_played_albums(&store, &LibraryScopeMostPlayedRequest { - scopes: scopes(), limit: Some(10), offset: None, - }).unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].play_count, 2); - assert_eq!(rows[0].album.server_id, "s1"); - assert_eq!(rows[0].album.id, "shared-album"); - assert_eq!(rows[0].album.song_count, Some(1)); - assert_eq!(rows[0].album.duration_sec, Some(200)); - } - - #[test] - fn most_played_song_count_does_not_collide_equal_raw_ids_across_servers() { - let store = LibraryStore::open_in_memory(); - let mut first = row("s1", "same-id", "shared-album", "a", 200); - first.title = "First".into(); - first.isrc = Some("first-isrc".into()); - let mut second = row("s2", "same-id", "shared-album", "b", 240); - second.title = "Second".into(); - second.isrc = Some("second-isrc".into()); - TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap(); - let repo = PlaySessionRepository::new(&store); - for (server_id, started_at_ms) in [("s1", 1000), ("s2", 2000)] { - repo.insert(&crate::dto::PlaySessionInputDto { - server_id: server_id.into(), track_id: "same-id".into(), started_at_ms, - listened_sec: 30.0, position_max_sec: 30.0, end_reason: "next".into(), - duration_sec_hint: None, - }).unwrap(); - } - let rows = most_played_albums(&store, &LibraryScopeMostPlayedRequest { - scopes: scopes(), limit: Some(10), offset: None, - }).unwrap(); - assert_eq!(rows[0].album.song_count, Some(2)); - assert_eq!(rows[0].album.duration_sec, Some(440)); - } - - #[test] - fn contributor_roles_keep_distinct_people_on_one_track() { - let store = LibraryStore::open_in_memory(); - TrackRepository::new(&store).upsert_batch(&[contributor_row( - "s1", "t1", "album-one", "a", - serde_json::json!([ - { "role": "composer", "artist": { "id": "c1", "name": "Composer One" } }, - { "role": "composer", "artist": { "id": "c2", "name": "Composer Two" } } - ]), - )]).unwrap(); - let artists = artists_by_role(&store, &LibraryScopeArtistRoleRequest { - scopes: vec![LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) }], - role: "composer".into(), limit: Some(10), - }).unwrap(); - assert_eq!(artists.iter().map(|artist| artist.name.as_str()).collect::>(), - vec!["Composer One", "Composer Two"]); - } - - #[test] - fn contributor_roles_merge_same_person_across_servers_by_contributor_identity() { - let store = LibraryStore::open_in_memory(); - TrackRepository::new(&store).upsert_batch(&[ - contributor_row( - "s1", "t1", "shared-album", "a", - serde_json::json!([{ "role": "composer", "artist": { "id": "composer-a", "name": "Béla Bartók" } }]), - ), - contributor_row( - "s2", "t2", "shared-album", "b", - serde_json::json!([{ "role": "composer", "artist": { "id": "composer-b", "name": "Bela Bartok" } }]), - ), - ]).unwrap(); - let artists = artists_by_role(&store, &LibraryScopeArtistRoleRequest { - scopes: scopes(), role: "composer".into(), limit: Some(10), - }).unwrap(); - assert_eq!(artists.len(), 1); - assert_eq!(artists[0].server_id, "s1"); - assert_eq!(artists[0].id, "composer-a"); - assert_eq!(artists[0].album_count, Some(1)); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f0779c1..0cf26a50 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,7 +106,6 @@ fn specta_builder() -> tauri_specta::Builder { .commands(tauri_specta::collect_commands![ crate::lib_commands::app_api::core::greet, psysonic_library::browse_support::library_get_catalog_year_bounds, - psysonic_library::browse_support::library_scope_catalog_year_bounds, psysonic_library::browse_support::library_get_genre_album_counts, // psysonic-library — remaining typeable commands. Excluded (stay on // generate_handler! only): the 10 search/browse/track reads whose envelopes @@ -124,8 +123,6 @@ fn specta_builder() -> tauri_specta::Builder { psysonic_library::commands::library_genre_tags_inspect, psysonic_library::commands::library_genre_tags_run, psysonic_library::commands::library_cluster_rebuild, - psysonic_library::commands::library_resolve_entity_sources, - psysonic_library::commands::library_scope_catalog_statistics, psysonic_library::commands::library_sync_bind_session, psysonic_library::commands::library_sync_clear_session, psysonic_library::commands::library_set_playback_hint, @@ -1046,15 +1043,11 @@ pub fn run() { psysonic_library::commands::library_genre_tags_inspect, psysonic_library::commands::library_genre_tags_run, psysonic_library::commands::library_cluster_rebuild, - psysonic_library::commands::library_resolve_entity_sources, psysonic_library::commands::library_scope_list_albums, psysonic_library::commands::library_scope_list_artists, psysonic_library::commands::library_scope_search_tracks, psysonic_library::commands::library_scope_album_detail, psysonic_library::commands::library_scope_artist_detail, - psysonic_library::commands::library_scope_catalog_statistics, - psysonic_library::commands::library_scope_most_played_albums, - psysonic_library::commands::library_scope_list_artists_by_role, psysonic_library::commands::library_get_artist_lossless_browse, psysonic_library::commands::library_search_cross_server, psysonic_library::commands::library_get_track, @@ -1077,7 +1070,6 @@ pub fn run() { psysonic_library::browse_support::library_patch_album, psysonic_library::browse_support::library_reconcile_album_stars, psysonic_library::browse_support::library_get_catalog_year_bounds, - psysonic_library::browse_support::library_scope_catalog_year_bounds, psysonic_library::browse_support::library_get_genre_album_counts, psysonic_library::commands::library_put_artifact, psysonic_library::commands::library_put_fact, @@ -1301,8 +1293,6 @@ mod specta_export { "library_scope_artist_detail", "library_scope_list_albums", "library_scope_list_artists", - "library_scope_list_artists_by_role", - "library_scope_most_played_albums", "library_scope_search_tracks", "library_search", "library_search_cross_server", diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index d19038c0..e49ed971 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -17,7 +17,6 @@ import AppRoutes from './AppRoutes'; import FullscreenPlayer, { FullscreenPlayerImmersive, FullscreenPlayerPrism } from '@/features/fullscreenPlayer'; import ContextMenu from '@/features/contextMenu/components/ContextMenu'; import SongInfoModal from '@/features/playback/components/SongInfoModal'; -import PlaybackAlternativeModal from '@/features/playback/components/PlaybackAlternativeModal'; import { DownloadFolderModal, OfflineBanner } from '@/features/offline/ui'; import GlobalConfirmModal from '@/ui/GlobalConfirmModal'; import ThemeMigrationNotice from '@/ui/ThemeMigrationNotice'; @@ -347,7 +346,6 @@ export function AppShell() { )} - diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 947f3650..5971af87 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -26,9 +26,8 @@ import { runLegacyOfflineFileMigration } from '@/features/offline/utils/legacyOf import { reconcileLibraryTierForServer } from '@/features/offline/utils/libraryTierReconcile'; import { initMiniPlayerBridgeOnMain } from '@/features/miniPlayer'; import { runAdvancedModeMigration } from '@/app/migrations/advancedModeMigration'; +import { bootstrapAllIndexedServers } from '@/lib/library/librarySession'; import { hydrateQueueFromIndex } from '@/features/playback/store/queueRestore'; -import { initPendingEntityMutationSync } from '@/features/playback/store/pendingStarSync'; -import { useLibraryIndexSync } from '@/lib/library/hooks/useLibraryIndexSync'; import { useLibraryAnalysisBackfill } from '@/lib/library/hooks/useLibraryAnalysisBackfill'; import { useCoverArtPrefetch } from '../cover/useCoverArtPrefetch'; import { useLibraryCoverBackfill } from '@/cover/useLibraryCoverBackfill'; @@ -69,10 +68,12 @@ export default function MainApp() { const migrationPhase = useMigrationStore(s => s.phase); const migrationReady = migrationPhase === 'completed'; useMigrationOrchestrator(); - useLibraryIndexSync(migrationReady); useEffect(() => { if (!migrationReady) return; - void hydrateQueueFromIndex(); + void (async () => { + await bootstrapAllIndexedServers(); + void hydrateQueueFromIndex(); + })(); }, [activeServerId, serverIdsKey, masterEnabled, migrationReady]); useLibraryAnalysisBackfill(migrationReady); @@ -104,11 +105,6 @@ export default function MainApp() { return initAudioListeners(); }, [migrationReady]); - useEffect(() => { - if (!migrationReady) return undefined; - return initPendingEntityMutationSync(); - }, [migrationReady]); - useEffect(() => { if (!migrationReady) return undefined; return initHotCachePrefetch(); diff --git a/src/app/hooks/useServerCapabilitiesProbe.ts b/src/app/hooks/useServerCapabilitiesProbe.ts index 3cbc2f01..35eafef5 100644 --- a/src/app/hooks/useServerCapabilitiesProbe.ts +++ b/src/app/hooks/useServerCapabilitiesProbe.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { getMusicFoldersForServer } from '@/lib/api/subsonicLibrary'; +import { getMusicFolders } from '@/lib/api/subsonicLibrary'; import { probeEntityRatingSupport } from '@/lib/api/subsonicStarRating'; import { useAuthStore } from '@/store/authStore'; import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit'; @@ -18,7 +18,7 @@ import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit'; export function useServerCapabilitiesProbe(): void { const isLoggedIn = useAuthStore(s => s.isLoggedIn); const activeServerId = useAuthStore(s => s.activeServerId); - const setMusicFoldersForServer = useAuthStore(s => s.setMusicFoldersForServer); + const setMusicFolders = useAuthStore(s => s.setMusicFolders); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); useEffect(() => { @@ -28,9 +28,11 @@ export function useServerCapabilitiesProbe(): void { (async () => { const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart; try { - const folders = await getMusicFoldersForServer(serverAtStart); - if (stillThisServer()) setMusicFoldersForServer(serverAtStart, folders); - } catch { /* Keep the persisted last-known folders on transient failure. */ } + const folders = await getMusicFolders(); + if (stillThisServer()) setMusicFolders(folders); + } catch { + if (stillThisServer()) setMusicFolders([]); + } try { const level = await probeEntityRatingSupport(); if (stillThisServer()) setEntityRatingSupport(serverAtStart, level); @@ -41,7 +43,7 @@ export function useServerCapabilitiesProbe(): void { return () => { cancelled = true; }; - }, [isLoggedIn, activeServerId, setMusicFoldersForServer, setEntityRatingSupport]); + }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); useEffect(() => { if (!isLoggedIn || !activeServerId) return; diff --git a/src/app/musicLibraryCatalogReloadBridge.test.ts b/src/app/musicLibraryCatalogReloadBridge.test.ts deleted file mode 100644 index d0199813..00000000 --- a/src/app/musicLibraryCatalogReloadBridge.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useAuthStore } from '@/store/authStore'; -import { useLibraryIndexStore } from '@/store/libraryIndexStore'; -import { flushMusicLibraryFilterVersionBumpForTests } from '@/store/musicLibraryFilterNotify'; -import { resetAuthStore } from '@/test/helpers/storeReset'; - -vi.mock('@/lib/library/artistBrowseInflight', () => ({ - clearArtistBrowseCatalogCache: vi.fn(), -})); -vi.mock('@/lib/library/albumBrowseCatalogPrefetch', () => ({ - prefetchAlbumBrowseCatalogAfterFilterChange: vi.fn(), -})); -vi.mock('@/lib/library/artistBrowseCatalogPrefetch', () => ({ - prefetchArtistBrowseCatalogAfterFilterChange: vi.fn(), -})); -vi.mock('@/store/offlineLocalLibrarySyncRevision', () => ({ - offlineLocalLibrarySyncRevision: () => 0, -})); - -import './musicLibraryCatalogReloadBridge'; - -function readyStatus(serverId: string) { - return { - serverId, - libraryScope: '', - syncPhase: 'ready', - capabilityFlags: 0, - libraryTier: '', - }; -} - -describe('musicLibraryCatalogReloadBridge runtime scope updates', () => { - beforeEach(() => { - resetAuthStore(); - useLibraryIndexStore.setState({ statusByServer: {}, connectionByServer: {} }); - useLibraryIndexStore.getState().replaceConnections({}); - }); - - it('coalesces readiness and reachability transitions into one version bump', () => { - const serverId = useAuthStore.getState().addServer({ - name: 'Server', url: 'https://music.example', username: 'u', password: 'p', - }); - useAuthStore.getState().setActiveServer(serverId); - - useLibraryIndexStore.getState().replaceStatuses({ - 'music.example': readyStatus('music.example'), - }); - useLibraryIndexStore.getState().replaceConnections({ 'music.example': 'online' }); - - expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(0); - flushMusicLibraryFilterVersionBumpForTests(); - expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(1); - }); - - it('bumps again when reconnect restores browse membership', () => { - const serverId = useAuthStore.getState().addServer({ - name: 'Server', url: 'https://music.example', username: 'u', password: 'p', - }); - useAuthStore.getState().setActiveServer(serverId); - useLibraryIndexStore.getState().replaceStatuses({ - 'music.example': readyStatus('music.example'), - }); - useLibraryIndexStore.getState().replaceConnections({ 'music.example': 'offline' }); - flushMusicLibraryFilterVersionBumpForTests(); - const versionBeforeReconnect = useAuthStore.getState().musicLibraryFilterVersion; - - useLibraryIndexStore.getState().replaceConnections({ 'music.example': 'online' }); - flushMusicLibraryFilterVersionBumpForTests(); - expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(versionBeforeReconnect + 1); - }); -}); diff --git a/src/app/musicLibraryCatalogReloadBridge.ts b/src/app/musicLibraryCatalogReloadBridge.ts index 385dadee..72db9482 100644 --- a/src/app/musicLibraryCatalogReloadBridge.ts +++ b/src/app/musicLibraryCatalogReloadBridge.ts @@ -1,20 +1,8 @@ import { clearArtistBrowseCatalogCache } from '@/lib/library/artistBrowseInflight'; import { prefetchAlbumBrowseCatalogAfterFilterChange } from '@/lib/library/albumBrowseCatalogPrefetch'; import { prefetchArtistBrowseCatalogAfterFilterChange } from '@/lib/library/artistBrowseCatalogPrefetch'; -import { - registerMusicLibraryCatalogReloadHandler, - runMusicLibraryCatalogReloadHandler, - scheduleMusicLibraryFilterVersionBump, -} from '@/store/musicLibraryFilterNotify'; +import { registerMusicLibraryCatalogReloadHandler } from '@/store/musicLibraryFilterNotify'; import { offlineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision'; -import { useAuthStore } from '@/store/authStore'; -import { useLibraryIndexStore } from '@/store/libraryIndexStore'; -import { - buildBrowseLibraryScopePairs, - libraryScopeFingerprint, -} from '@/lib/library/libraryBrowseScope'; -import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint'; -import { registerLibraryServerConnectionPublisher } from '@/lib/network/libraryServerReachability'; /** * App-layer seam wiring the store's music-library filter/selection change to the @@ -33,42 +21,3 @@ registerMusicLibraryCatalogReloadHandler((serverId, indexEnabled, version) => { prefetchAlbumBrowseCatalogAfterFilterChange(serverId, version, indexEnabled, syncRevision); prefetchArtistBrowseCatalogAfterFilterChange(serverId, version, indexEnabled, syncRevision); }); - -registerLibraryServerConnectionPublisher((indexKey, connection) => { - useLibraryIndexStore.getState().mergeConnections({ [indexKey]: connection }); -}); - -function currentBrowseFingerprint(): string { - const runtime = useLibraryIndexStore.getState(); - return libraryScopeFingerprint(buildBrowseLibraryScopePairs( - useAuthStore.getState(), - runtime, - { navigatorOffline: isNavigatorOfflineHint() }, - )); -} - -let previousBrowseFingerprint = currentBrowseFingerprint(); - -function scheduleReloadIfBrowseScopeChanged(): void { - const next = currentBrowseFingerprint(); - if (next === previousBrowseFingerprint) return; - previousBrowseFingerprint = next; - scheduleMusicLibraryFilterVersionBump(() => { - useAuthStore.setState(state => ({ - musicLibraryFilterVersion: state.musicLibraryFilterVersion + 1, - })); - const auth = useAuthStore.getState(); - const serverId = auth.activeServerId ?? auth.musicLibraryServerIds[0]; - if (!serverId) return; - runMusicLibraryCatalogReloadHandler( - serverId, - useLibraryIndexStore.getState().isIndexEnabled(serverId), - auth.musicLibraryFilterVersion, - ); - }); -} - -useAuthStore.subscribe(scheduleReloadIfBrowseScopeChanged); -useLibraryIndexStore.subscribe(scheduleReloadIfBrowseScopeChanged); -window.addEventListener('online', scheduleReloadIfBrowseScopeChanged); -window.addEventListener('offline', scheduleReloadIfBrowseScopeChanged); diff --git a/src/app/tauriBridge/usePlayerSnapshotPublisher.ts b/src/app/tauriBridge/usePlayerSnapshotPublisher.ts index daab348e..47b6a3e5 100644 --- a/src/app/tauriBridge/usePlayerSnapshotPublisher.ts +++ b/src/app/tauriBridge/usePlayerSnapshotPublisher.ts @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress'; import { usePlayerStore } from '@/features/playback/store/playerStore'; -import { entityOverrideKey } from '@/lib/media/entityOverrideKey'; import { useAuthStore } from '@/store/authStore'; import { resolveQueueTrack } from '@/features/playback/store/queueTrackView'; @@ -28,14 +27,10 @@ export function usePlayerSnapshotPublisher() { const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all'; const ct = s.currentTrack; const currentTrackUserRating = - ct != null - ? (s.userRatingOverrides[entityOverrideKey(ct.serverId ?? s.queueServerId, ct.id)] ?? ct.userRating ?? null) - : null; + ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null; const currentTrackStarred = ct != null - ? (entityOverrideKey(ct.serverId ?? s.queueServerId, ct.id) in s.starredOverrides - ? s.starredOverrides[entityOverrideKey(ct.serverId ?? s.queueServerId, ct.id)] - : Boolean(ct.starred)) + ? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred)) : null; // Thin-state: resolve only a window around the playing track (resolver // cache → placeholder) instead of the whole 50k queue. `queue_length` diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 114e7640..2aff7c1a 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -208,7 +208,6 @@ const CONTRIBUTOR_ENTRIES = [ 'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)', 'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)', 'Playlists browse — scoped header search by playlist name (PR #1308)', - 'Unified multi-server Library scope with source-safe playback, mutations, playlists, radio, folders, Offline and sharing (PR #1309)', ], }, { diff --git a/src/config/shortcutDispatch.ts b/src/config/shortcutDispatch.ts index 3c602f84..17649d80 100644 --- a/src/config/shortcutDispatch.ts +++ b/src/config/shortcutDispatch.ts @@ -97,7 +97,7 @@ export function executeCliPlayerCommand(ctx: CliContext): void | Promise { showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); return; } - queueSongRating(track.id, stars, track.serverId); + queueSongRating(track.id, stars); return; } // no-op for unknown command diff --git a/src/features/album/components/AlbumCard.tsx b/src/features/album/components/AlbumCard.tsx index 10932e95..99600a33 100644 --- a/src/features/album/components/AlbumCard.tsx +++ b/src/features/album/components/AlbumCard.tsx @@ -1,6 +1,6 @@ import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import React, { memo, useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useNavigateToAlbum } from '@/features/album/hooks/useNavigateToAlbum'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -24,8 +24,6 @@ import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency'; import { albumArtistDisplayName, deriveAlbumArtistRefs } from '@/features/album/utils/deriveAlbumHeaderArtistRefs'; import { coverServerScopeForServerId } from '@/cover/serverScope'; import { appendServerQuery } from '@/lib/navigation/detailServerScope'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; -import { navigateToArtistDetail } from '@/lib/navigation/albumDetailNavigation'; interface AlbumCardProps { album: SubsonicAlbum; @@ -70,7 +68,6 @@ function AlbumCard({ onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined), }); const navigate = useNavigate(); - const location = useLocation(); const navigateToAlbum = useNavigateToAlbum(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const enqueue = usePlayerStore(s => s.enqueue); @@ -99,7 +96,7 @@ function AlbumCard({ const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]); const handleClick = (opts?: { shiftKey?: boolean }) => { - if (selectionMode) { onToggleSelect?.(libraryEntityKey(album), opts); return; } + if (selectionMode) { onToggleSelect?.(album.id, opts); return; } navigateToAlbum(album.id, { search: albumLinkQuery }); }; @@ -218,10 +215,7 @@ function AlbumCard({ { - const search = appendServerQuery(undefined, album.serverId); - navigateToArtistDetail(navigate, location, id, search ? { search } : undefined); - }} + onGoArtist={id => navigate(`/artist/${id}`)} as="none" linkTag="span" linkClassName="track-artist-link" diff --git a/src/features/album/components/AlbumHeader.tsx b/src/features/album/components/AlbumHeader.tsx index 65975f06..cd2577cd 100644 --- a/src/features/album/components/AlbumHeader.tsx +++ b/src/features/album/components/AlbumHeader.tsx @@ -145,7 +145,6 @@ interface AlbumInfo { coverArt?: string; recordLabel?: string; created?: string; - serverId?: string; } interface AlbumHeaderProps { @@ -231,7 +230,7 @@ export default function AlbumHeader({ const handleShareAlbum = async () => { try { - const ok = await copyEntityShareLink('album', info.id, info.serverId); + const ok = await copyEntityShareLink('album', info.id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } catch { diff --git a/src/features/album/components/AlbumRow.tsx b/src/features/album/components/AlbumRow.tsx index 253cbc5d..d9e39098 100644 --- a/src/features/album/components/AlbumRow.tsx +++ b/src/features/album/components/AlbumRow.tsx @@ -6,7 +6,6 @@ import { NavLink, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { dedupeById } from '@/lib/util/dedupeById'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; interface Props { title: string; @@ -138,7 +137,7 @@ export default function AlbumRow({ // Reset when the row’s identity changes (new data / server), not when the list grows via // “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders. - const rowArtworkResetKey = uniqueAlbums[0] ? libraryEntityKey(uniqueAlbums[0]) : ''; + const rowArtworkResetKey = uniqueAlbums[0]?.id ?? ''; useEffect(() => { // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -262,7 +261,7 @@ export default function AlbumRow({
{uniqueAlbums.map((a, idx) => ( s.activeServerId); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || activeServerId || ''; - const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(browseServerId)); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId ?? '')); const [albums, setAlbums] = useState([]); useEffect(() => { let cancelled = false; (async () => { - if (indexEnabled && browseServerId) { - const local = await runLocalLosslessAlbums( - browseServerId, - TARGET_ALBUMS, - 0, - browseScope.pairs, - ); + if (indexEnabled && activeServerId) { + const local = await runLocalLosslessAlbums(activeServerId, TARGET_ALBUMS, 0); if (cancelled) return; if (local && local.albums.length > 0) { setAlbums(local.albums); return; } } - if (browseScope.multiServer) { - setAlbums([]); - return; - } try { const page = await ndListLosslessAlbumsPage({ targetNewAlbums: TARGET_ALBUMS }); if (cancelled) return; @@ -60,7 +48,7 @@ export default function LosslessAlbumsRail({ } })(); return () => { cancelled = true; }; - }, [browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId, indexEnabled]); + }, [activeServerId, indexEnabled]); if (albums.length === 0) return null; diff --git a/src/features/album/hooks/loadAlbumDetailMultiScope.test.ts b/src/features/album/hooks/loadAlbumDetailMultiScope.test.ts index e66b85aa..75b78bd3 100644 --- a/src/features/album/hooks/loadAlbumDetailMultiScope.test.ts +++ b/src/features/album/hooks/loadAlbumDetailMultiScope.test.ts @@ -5,6 +5,10 @@ const libraryScopeAlbumDetailMock = vi.fn(); vi.mock('@/lib/api/library/scopeReads', () => ({ libraryScopeAlbumDetail: (...args: unknown[]) => libraryScopeAlbumDetailMock(...args), + scopePairsFromLibrarySelection: (serverId: string) => [ + { serverId: `${serverId}-idx`, libraryId: 'lib-a' }, + { serverId: `${serverId}-idx`, libraryId: 'lib-b' }, + ], })); import { tryLoadAlbumDetailMultiScope } from './loadAlbumDetailMultiScope'; @@ -49,10 +53,7 @@ describe('tryLoadAlbumDetailMultiScope', () => { tracks: [trackDto(), trackDto({ id: 'trk-2', title: 'Track Two', trackNumber: 2 })], }); - const result = await tryLoadAlbumDetailMultiScope('srv-1', 'alb-1', [ - { serverId: 'srv-1-idx', libraryId: 'lib-a' }, - { serverId: 'srv-1-idx', libraryId: 'lib-b' }, - ]); + const result = await tryLoadAlbumDetailMultiScope('srv-1', 'alb-1'); expect(libraryScopeAlbumDetailMock).toHaveBeenCalledWith('srv-1', { scopes: [ @@ -73,12 +74,12 @@ describe('tryLoadAlbumDetailMultiScope', () => { tracks: [], }); - await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1', [])).resolves.toBeNull(); + await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1')).resolves.toBeNull(); }); it('returns null when the scope command throws', async () => { libraryScopeAlbumDetailMock.mockRejectedValue(new Error('ipc fail')); - await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1', [])).resolves.toBeNull(); + await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1')).resolves.toBeNull(); }); }); diff --git a/src/features/album/hooks/loadAlbumDetailMultiScope.ts b/src/features/album/hooks/loadAlbumDetailMultiScope.ts index b12492be..16bfd4bb 100644 --- a/src/features/album/hooks/loadAlbumDetailMultiScope.ts +++ b/src/features/album/hooks/loadAlbumDetailMultiScope.ts @@ -1,7 +1,7 @@ import { libraryScopeAlbumDetail, + scopePairsFromLibrarySelection, } from '@/lib/api/library/scopeReads'; -import type { LibraryScopePair } from '@/lib/api/library'; import { albumToAlbum, trackToSong } from '@/lib/library/advancedSearchLocal'; import type { ResolvedAlbum } from '@/features/offline'; @@ -12,11 +12,10 @@ import type { ResolvedAlbum } from '@/features/offline'; export async function tryLoadAlbumDetailMultiScope( serverId: string, albumId: string, - scopes: LibraryScopePair[], ): Promise { try { const response = await libraryScopeAlbumDetail(serverId, { - scopes, + scopes: scopePairsFromLibrarySelection(serverId), albumId, serverId, }); diff --git a/src/features/album/hooks/useAlbumBrowseData.ts b/src/features/album/hooks/useAlbumBrowseData.ts index cf3d85a2..0d395e77 100644 --- a/src/features/album/hooks/useAlbumBrowseData.ts +++ b/src/features/album/hooks/useAlbumBrowseData.ts @@ -64,7 +64,6 @@ import { storeAlbumBrowseCatalogCache, } from '@/lib/library/albumBrowseInflight'; import { librarySelectionForServer } from '@/lib/api/subsonicClient'; -import type { LibraryScopePair } from '@/lib/api/library'; const PAGE_SIZE = 30; const CLIENT_SLICE_PAGE_SIZE = 60; @@ -93,9 +92,6 @@ export type UseAlbumBrowseDataArgs = { scrollRootEl?: HTMLElement | null; /** Bootstrap visible slice size when restoring scroll after album-detail back. */ restoreDisplayCount?: number; - scopePairs?: LibraryScopePair[]; - scopeFingerprint?: string; - localOnly?: boolean; }; function resolveHasMoreAfterPage( @@ -124,9 +120,6 @@ export function useAlbumBrowseData({ getScrollRoot, scrollRootEl, restoreDisplayCount, - scopePairs, - scopeFingerprint = '', - localOnly = false, }: UseAlbumBrowseDataArgs) { const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); @@ -179,14 +172,13 @@ export function useAlbumBrowseData({ browseQuery, offlineBrowseActive, ); - const scopedBase = `${base}\0${scopeFingerprint}`; // Online index browse re-keys on the library sync revision so a completed // resync surfaces renamed/pruned albums without an app restart; offline // browse already re-keys via its own (sync-driven) reload key. - if (!offlineBrowseActive) return albumBrowseOnlineCatalogKey(scopedBase, librarySyncRevision); - return `${scopedBase}\0${offlineLocalBrowseReloadKey}`; + if (!offlineBrowseActive) return albumBrowseOnlineCatalogKey(base, librarySyncRevision); + return `${base}\0${offlineLocalBrowseReloadKey}`; }, - [serverId, musicLibraryFilterVersion, browseQuery, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision, scopeFingerprint], + [serverId, musicLibraryFilterVersion, browseQuery, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision], ); const compFilterActive = compFilter !== 'all'; @@ -214,7 +206,6 @@ export function useAlbumBrowseData({ compFilter, musicLibraryFilterVersion, serverId, - scopeFingerprint, ], getScrollRoot, scrollRootEl, @@ -303,8 +294,7 @@ export function useAlbumBrowseData({ query, offset, CATALOG_CHUNK_SIZE, - starredOverrides, - scopePairs, + starredOverrides, ); if (generation !== loadGenerationRef.current || chunk == null) return; setAlbums(prev => { @@ -323,7 +313,7 @@ export function useAlbumBrowseData({ // reloads from the right source when offline browse toggles; the loader reads // the active mode internally rather than referencing the flag directly here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [indexEnabled, offlineBrowseActive, scopePairs, serverId, starredOverrides]); + }, [indexEnabled, offlineBrowseActive, serverId, starredOverrides]); const loadBrowse = useCallback(async ( query: AlbumBrowseQuery, @@ -373,8 +363,6 @@ export function useAlbumBrowseData({ } }, }, - scopePairs, - localOnly, ), { offset, pageSize: PAGE_SIZE, append }, ); @@ -389,7 +377,7 @@ export function useAlbumBrowseData({ else setLoading(false); } } - }, [indexEnabled, localOnly, scopePairs, serverId]); + }, [indexEnabled, serverId]); useLayoutEffect(() => { const cached = readAlbumBrowseCatalogCache(catalogLoadKey); @@ -513,9 +501,8 @@ export function useAlbumBrowseData({ serverId, indexEnabled, browseQuery, - 0, - ALBUM_BROWSE_BOOTSTRAP_CHUNK, - scopePairs, + 0, + ALBUM_BROWSE_BOOTSTRAP_CHUNK, ), { chunkSize: ALBUM_BROWSE_BOOTSTRAP_CHUNK }, ), @@ -556,7 +543,6 @@ export function useAlbumBrowseData({ browseQuery, tailOffset, tailSize, - scopePairs, ), { offset: tailOffset, chunkSize: tailSize }, ), @@ -603,7 +589,6 @@ export function useAlbumBrowseData({ browseQuery, 0, CATALOG_CHUNK_SIZE, - scopePairs, ), ), ); @@ -629,14 +614,6 @@ export function useAlbumBrowseData({ } } if (cancelled) return; - if (localOnly) { - setBrowseMode('slice'); - setAlbums([]); - setCatalogHasMore(false); - setLoading(false); - emitAlbumBrowseDebug('load_effect_done', { browseMode: 'slice', localUnavailable: true }); - return; - } emitAlbumBrowseDebug('load_branch', { mode: 'page' }); setBrowseMode('page'); await loadBrowse(browseQuery, 0, false); @@ -651,7 +628,7 @@ export function useAlbumBrowseData({ // starredOverrides is read to seed star state during the load, but the browse // list must not reload on every star toggle — it is intentionally excluded. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [catalogLoadKey, browseQuery, indexEnabled, localOnly, offlineBrowseActive, offlineBrowseReloadTs, scopePairs, serverId, loadBrowse, musicLibraryFilterVersion]); + }, [catalogLoadKey, browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]); useEffect(() => { if (!genreCatalogActive) { @@ -668,7 +645,7 @@ export function useAlbumBrowseData({ 'genre_options', () => offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId) ? fetchOfflineLocalAlbumGenreOptions(serverId, browseQueryWithoutGenre, starredOverrides) - : fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre, scopePairs), + : fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre), ).then(options => { if (!cancelled) { setGenreCatalogOptions(options); @@ -686,7 +663,6 @@ export function useAlbumBrowseData({ browseQueryWithoutGenre, musicLibraryFilterVersion, offlineBrowseActive, - scopePairs, starredOverrides, ]); diff --git a/src/features/album/hooks/useAlbumDetailData.multiScope.test.ts b/src/features/album/hooks/useAlbumDetailData.multiScope.test.ts index 12355267..7795feb3 100644 --- a/src/features/album/hooks/useAlbumDetailData.multiScope.test.ts +++ b/src/features/album/hooks/useAlbumDetailData.multiScope.test.ts @@ -7,26 +7,11 @@ import { useAuthStore } from '@/store/authStore'; const tryLoadAlbumDetailMultiScopeMock = vi.fn(); const resolveAlbumMock = vi.fn(); const librarySelectionForServerMock = vi.fn(); -let browseScope = { - pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }], - fingerprint: 'multi', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1', 'srv-2'], - multiServer: true, -}; vi.mock('@/features/album/hooks/loadAlbumDetailMultiScope', () => ({ tryLoadAlbumDetailMultiScope: (...args: unknown[]) => tryLoadAlbumDetailMultiScopeMock(...args), })); -vi.mock('@/lib/library/loadArtistDetailMultiScope', () => ({ - tryLoadArtistDetailMultiScope: vi.fn().mockResolvedValue(null), -})); - -vi.mock('@/store/useBrowseLibraryScope', () => ({ - useBrowseLibraryScope: () => browseScope, -})); - vi.mock('@/lib/api/subsonicClient', async importOriginal => { const actual = await importOriginal(); return { @@ -64,13 +49,6 @@ describe('useAlbumDetailData — multi-library selection', () => { tryLoadAlbumDetailMultiScopeMock.mockReset(); resolveAlbumMock.mockReset(); librarySelectionForServerMock.mockReset(); - browseScope = { - pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }], - fingerprint: 'multi', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1', 'srv-2'], - multiServer: true, - }; useAuthStore.setState({ activeServerId: 'srv-1', servers: [{ id: 'srv-1', name: 'S', url: 'https://s.test', username: 'u', password: 'p' }], @@ -94,7 +72,7 @@ describe('useAlbumDetailData — multi-library selection', () => { const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1', browseScope.pairs); + expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1'); expect(resolveAlbumMock).not.toHaveBeenCalled(); expect(result.current.album?.album).toMatchObject({ id: 'alb-1', name: 'Merged' }); expect(result.current.album?.songs).toHaveLength(1); @@ -110,19 +88,12 @@ describe('useAlbumDetailData — multi-library selection', () => { const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1', browseScope.pairs); + expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1'); expect(resolveAlbumMock).not.toHaveBeenCalled(); expect(result.current.album?.album).toMatchObject({ name: 'Scoped' }); }); it('does not call tryLoadAlbumDetailMultiScope when all libraries are selected', async () => { - browseScope = { - pairs: [], - fingerprint: 'single', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1'], - multiServer: false, - }; librarySelectionForServerMock.mockReturnValue([]); resolveAlbumMock.mockResolvedValue({ album: { id: 'alb-1', name: 'Single' }, @@ -137,7 +108,7 @@ describe('useAlbumDetailData — multi-library selection', () => { expect(result.current.album?.album).toMatchObject({ id: 'alb-1', name: 'Single' }); }); - it('does not fall through to network when multi-server scope load returns null', async () => { + it('falls through to resolveAlbum when multi-scope load returns null', async () => { librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']); tryLoadAlbumDetailMultiScopeMock.mockResolvedValue(null); resolveAlbumMock.mockResolvedValue({ @@ -149,7 +120,7 @@ describe('useAlbumDetailData — multi-library selection', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalled(); - expect(resolveAlbumMock).not.toHaveBeenCalled(); - expect(result.current.album).toBeNull(); + expect(resolveAlbumMock).toHaveBeenCalled(); + expect(result.current.album?.album).toMatchObject({ name: 'Fallback' }); }); }); diff --git a/src/features/album/hooks/useAlbumDetailData.ts b/src/features/album/hooks/useAlbumDetailData.ts index cd5e3a16..9636a151 100644 --- a/src/features/album/hooks/useAlbumDetailData.ts +++ b/src/features/album/hooks/useAlbumDetailData.ts @@ -22,9 +22,8 @@ import { shouldAttemptSubsonicForActiveServer, shouldAttemptSubsonicForServer, } from '@/lib/network/subsonicNetworkGuard'; +import { librarySelectionForServer } from '@/lib/api/subsonicClient'; import { tryLoadAlbumDetailMultiScope } from '@/features/album/hooks/loadAlbumDetailMultiScope'; -import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; type AlbumPayload = ResolvedAlbum; @@ -53,7 +52,6 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [searchParams] = useSearchParams(); const detailServerId = readDetailServerId(searchParams, activeServerId); - const browseScope = useBrowseLibraryScope(); const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId; useEffect(() => { @@ -79,15 +77,6 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe ) => { if (!artistId) return; try { - if (browseScope.multiServer) { - const scoped = await tryLoadArtistDetailMultiScope( - serverId ?? browseScope.anchorServerId, - artistId, - browseScope.pairs, - ); - if (scoped) setRelatedAlbums(scoped.albums.filter(a => a.id !== id)); - return; - } if (useLocalArtist && serverId) { const artistLocal = localBytesOnly ? await loadArtistFromLocalPlayback(serverId, artistId) @@ -125,17 +114,13 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe return; } - if (detailServerId && browseScope.pairs.length > 0) { - const multi = await tryLoadAlbumDetailMultiScope(detailServerId, id, browseScope.pairs); + if (detailServerId && librarySelectionForServer(detailServerId).length > 0) { + const multi = await tryLoadAlbumDetailMultiScope(detailServerId, id); if (multi) { applyAlbumPayload(multi); await loadRelatedAlbums(detailServerId, multi.album.artistId, true, false); return; } - if (browseScope.multiServer) { - setLoading(false); - return; - } } // Index-first when the local SQLite index is ready, not only when the @@ -203,10 +188,6 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe })(); }, [ activeServerId, - browseScope.anchorServerId, - browseScope.fingerprint, - browseScope.multiServer, - browseScope.pairs, detailServerId, favoritesOfflineEnabled, id, diff --git a/src/features/album/hooks/useAlbumOfflineState.test.ts b/src/features/album/hooks/useAlbumOfflineState.test.ts index f50a8763..f4fbb403 100644 --- a/src/features/album/hooks/useAlbumOfflineState.test.ts +++ b/src/features/album/hooks/useAlbumOfflineState.test.ts @@ -11,7 +11,6 @@ describe('useAlbumOfflineState', () => { it('reports queued when the album waits in the pin queue', () => { useOfflineJobStore.setState({ pinQueue: [{ - serverId: 'srv', albumId: 'alb-1', albumName: 'One', pinKind: 'album', @@ -28,7 +27,6 @@ describe('useAlbumOfflineState', () => { it('prefers downloading over queued when jobs are active', () => { useOfflineJobStore.setState({ pinQueue: [{ - serverId: 'srv', albumId: 'alb-1', albumName: 'One', pinKind: 'album', diff --git a/src/features/album/hooks/useAlbumOfflineState.ts b/src/features/album/hooks/useAlbumOfflineState.ts index 3b660345..cf7c0903 100644 --- a/src/features/album/hooks/useAlbumOfflineState.ts +++ b/src/features/album/hooks/useAlbumOfflineState.ts @@ -36,23 +36,23 @@ export function useAlbumOfflineState( const isPinQueued = useOfflineJobStore(s => !pinComplete && !!albumId - && s.pinQueue.some(p => p.albumId === albumId && p.serverId === serverId && p.status === 'queued'), + && s.pinQueue.some(p => p.albumId === albumId && p.status === 'queued'), ); const isOfflineDownloading = useOfflineJobStore(s => !pinComplete && !!albumId && ( - s.pinQueue.some(p => p.albumId === albumId && p.serverId === serverId && p.status === 'downloading') - || s.jobs.some(j => j.albumId === albumId && (!j.serverId || j.serverId === serverId) && (j.status === 'queued' || j.status === 'downloading')) + s.pinQueue.some(p => p.albumId === albumId && p.status === 'downloading') + || s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')) ), ); const offlineProgressDone = useOfflineJobStore(s => { if (!albumId || pinComplete) return 0; - return s.jobs.filter(j => j.albumId === albumId && (!j.serverId || j.serverId === serverId) && (j.status === 'done' || j.status === 'error')).length; + return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length; }); const offlineProgressTotal = useOfflineJobStore(s => { if (!albumId || pinComplete) return 0; - return s.jobs.filter(j => j.albumId === albumId && (!j.serverId || j.serverId === serverId)).length; + return s.jobs.filter(j => j.albumId === albumId).length; }); const resolvedOfflineStatus = pinComplete ? 'cached' diff --git a/src/features/album/hooks/useBrowseAlbumTextSearch.ts b/src/features/album/hooks/useBrowseAlbumTextSearch.ts index a5d92d7a..d8c20e4c 100644 --- a/src/features/album/hooks/useBrowseAlbumTextSearch.ts +++ b/src/features/album/hooks/useBrowseAlbumTextSearch.ts @@ -10,7 +10,6 @@ import { } from '@/lib/library/browseTextSearch'; import { useOfflineBrowseContext } from '@/features/offline'; import { offlineLocalBrowseEnabled, searchOfflineLocalAlbums } from '@/features/offline'; -import type { LibraryScopePair } from '@/lib/api/library'; /** * Debounced album title search with local-vs-network race when the @@ -21,8 +20,6 @@ export function useBrowseAlbumTextSearch( indexEnabled: boolean, serverId: string | null | undefined, losslessOnly = false, - scopePairs?: LibraryScopePair[], - localOnly = false, ) { const offlineBrowseActive = useOfflineBrowseContext().active; const [debouncedFilter, setDebouncedFilter] = useState(''); @@ -60,7 +57,7 @@ export function useBrowseAlbumTextSearch( setTextSearchLoading(false); return; } - if (!indexEnabled && !localOnly) { + if (!indexEnabled) { const albums = await runNetworkBrowseAlbums(q); if (isStale()) return; setTextSearchAlbums(albums); @@ -68,17 +65,9 @@ export function useBrowseAlbumTextSearch( return; } - if (localOnly) { - const albums = await runLocalBrowseAlbums(serverId, q, undefined, losslessOnly, scopePairs); - if (isStale()) return; - setTextSearchAlbums(albums ?? []); - setTextSearchLoading(false); - return; - } - const outcome = await raceBrowseWithLocalFallback( isStale, - () => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly, scopePairs), + () => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly), () => runNetworkBrowseAlbums(q), { surface: 'albums_browse', @@ -91,7 +80,7 @@ export function useBrowseAlbumTextSearch( setTextSearchAlbums(outcome?.result ?? null); setTextSearchLoading(false); })(); - }, [debouncedFilter, indexEnabled, localOnly, offlineBrowseActive, scopePairs, serverId, losslessOnly]); + }, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]); const effectiveFilter = textSearchAlbums != null ? '' : filter; return { textSearchAlbums, textSearchLoading, effectiveFilter }; diff --git a/src/features/album/pages/AlbumDetail.tsx b/src/features/album/pages/AlbumDetail.tsx index e47e6f12..62e3bfab 100644 --- a/src/features/album/pages/AlbumDetail.tsx +++ b/src/features/album/pages/AlbumDetail.tsx @@ -1,5 +1,6 @@ import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl'; -import { queueEntityRating, queueEntityStar, queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync'; +import { setRating, star, unstar } from '@/lib/api/subsonicStarRating'; +import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync'; import { getAlbumForServer } from '@/lib/api/subsonicLibrary'; import { getArtistInfo } from '@/lib/api/subsonicArtists'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; @@ -35,6 +36,7 @@ import { } from '@/cover/ref'; import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef'; import { useTranslation } from 'react-i18next'; +import { showToast } from '@/lib/dom/toast'; import { useSelectionStore } from '@/store/selectionStore'; import { sanitizeFilename } from '@/features/album/utils/albumDetailHelpers'; import { albumArtistDisplayName, deriveAlbumHeaderArtistRefs } from '@/features/album/utils/deriveAlbumHeaderArtistRefs'; @@ -77,6 +79,7 @@ export default function AlbumDetail() { const deleteAlbum = useOfflineStore(s => s.deleteAlbum); const serverId = readDetailServerId(searchParams, auth.activeServerId) ?? ''; const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown'; const offlineCtx = useOfflineBrowseContext(); const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active); @@ -214,13 +217,16 @@ const handleShuffleAll = () => { const handleRate = (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); // F4: optimistic override + retried server sync via the central helper. - const song = album?.songs.find(candidate => candidate.id === songId); - queueSongRating(songId, rating, song?.serverId ?? serverId); + queueSongRating(songId, rating); }; const handleAlbumEntityRating = async (rating: number) => { if (!album || album.album.id !== id) return; const albumId = album.album.id; + const ratingAtStart = albumId in userRatingOverrides + ? userRatingOverrides[albumId] + : (album.album.userRating ?? 0); + userMetadataMutationRef.current = true; setUserRatingOverride(albumId, rating); @@ -229,13 +235,24 @@ const handleShuffleAll = () => { return; } - queueEntityRating('album', albumId, rating, album.album.serverId ?? serverId); - setAlbum(cur => - cur && cur.album.id === albumId - ? { ...cur, album: { ...cur.album, userRating: rating } } - : cur, - ); - userMetadataMutationRef.current = false; + try { + await setRating(albumId, rating); + setAlbum(cur => + cur && cur.album.id === albumId + ? { ...cur, album: { ...cur.album, userRating: rating } } + : cur, + ); + } catch (err) { + setUserRatingOverride(albumId, ratingAtStart); + setEntityRatingSupport(serverId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + } finally { + userMetadataMutationRef.current = false; + } }; const handleBio = async () => { @@ -272,6 +289,7 @@ const handleShuffleAll = () => { const toggleStar = async () => { if (!album) return; const wasStarred = isStarred; + const previousStarred = album.album.starred; const nextStarred = !wasStarred; userMetadataMutationRef.current = true; setStarredOverride(album.album.id, nextStarred); @@ -282,8 +300,30 @@ const handleShuffleAll = () => { starred: nextStarred ? (prev.album.starred ?? new Date().toISOString()) : undefined, }, } : prev); - queueEntityStar('album', album.album.id, nextStarred, album.album.serverId ?? serverId); - userMetadataMutationRef.current = false; + try { + const meta = { + serverId: serverId || album.album.serverId, + name: album.album.name, + artist: album.album.artist, + artistId: album.album.artistId, + coverArtId: album.album.coverArt, + year: album.album.year, + }; + if (wasStarred) await unstar(album.album.id, 'album', meta); + else await star(album.album.id, 'album', meta); + } catch (e) { + console.error('Failed to toggle star', e); + setStarredOverride(album.album.id, wasStarred); + setAlbum(prev => prev ? { + ...prev, + album: { + ...prev.album, + starred: wasStarred ? previousStarred : undefined, + }, + } : prev); + } finally { + userMetadataMutationRef.current = false; + } }; const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => { diff --git a/src/features/album/pages/Albums.tsx b/src/features/album/pages/Albums.tsx index a87d929c..3b2fb364 100644 --- a/src/features/album/pages/Albums.tsx +++ b/src/features/album/pages/Albums.tsx @@ -1,4 +1,4 @@ -import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl'; +import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl'; import { resolveAlbum } from '@/features/offline'; import { songToTrack } from '@/lib/media/songToTrack'; import { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'; @@ -59,8 +59,6 @@ import { emitAlbumBrowseDebug, } from '@/lib/library/albumBrowseDebug'; import { librarySelectionForServer } from '@/lib/api/subsonicClient'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; type SortType = AlbumBrowseSort; @@ -75,8 +73,6 @@ export default function Albums() { const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || serverId; const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); useLayoutEffect(() => { @@ -94,7 +90,7 @@ export default function Albums() { const scrollSnapshotRef = useRef({ scrollTop: 0, displayCount: 0 }); const restoreDisplayCountRef = useRef( - peekAlbumBrowseScrollRestore(`${serverId}\0${browseScope.fingerprint}`, 'albums')?.displayCount, + peekAlbumBrowseScrollRestore(serverId, 'albums')?.displayCount, ); const { @@ -112,16 +108,14 @@ export default function Albums() { setStarredOnly, losslessOnly, setLosslessOnly, - } = useAlbumBrowseFilters(`${serverId}\0${browseScope.fingerprint}`, scrollSnapshotRef); + } = useAlbumBrowseFilters(serverId, scrollSnapshotRef); const albumsSearchQuery = useScopedBrowseSearchQuery('albums'); const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch( albumsSearchQuery, indexEnabled, - browseServerId, + serverId, losslessOnly, - browseScope.pairs, - browseScope.multiServer, ); const { @@ -132,7 +126,7 @@ export default function Albums() { const starredOverrides = usePlayerStore(s => s.starredOverrides); const browseData = useAlbumBrowseData({ - serverId: browseServerId, + serverId, indexEnabled, musicLibraryFilterVersion, sort, @@ -148,9 +142,6 @@ export default function Albums() { // React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output. // eslint-disable-next-line react-hooks/refs restoreDisplayCount: restoreDisplayCountRef.current, - scopePairs: browseScope.pairs, - scopeFingerprint: browseScope.fingerprint, - localOnly: browseScope.multiServer, }); const textSearchActive = textSearchAlbums != null; @@ -289,7 +280,7 @@ export default function Albums() { resetSelection(); }; - const selectedAlbums = displayAlbums.filter(a => selectedIds.has(libraryEntityKey(a))); + const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id)); const enqueue = usePlayerStore(state => state.enqueue); const handleEnqueueSelected = async () => { @@ -297,7 +288,7 @@ export default function Albums() { try { // Parallel album resolves — Navidrome handles concurrent requests fine. const results = await Promise.all( - selectedAlbums.map(a => resolveAlbum(a.serverId ?? serverId, a.id).catch(() => null)), + selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)), ); const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []); if (tracks.length > 0) { @@ -323,8 +314,7 @@ export default function Albums() { const downloadId = crypto.randomUUID(); const filename = `${sanitizeFilename(album.name)}.zip`; const destPath = await join(folder, filename); - const ownerServerId = album.serverId ?? serverId; - const url = buildDownloadUrlForServer(ownerServerId, album.id); + const url = buildDownloadUrl(album.id); start(downloadId, filename); try { await downloadZip({ id: downloadId, url, destPath }); @@ -342,10 +332,9 @@ export default function Albums() { let queued = 0; for (const album of selectedAlbums) { try { - const ownerServerId = album.serverId ?? serverId; - const detail = await resolveAlbum(ownerServerId, album.id); + const detail = await resolveAlbum(serverId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); @@ -561,7 +550,7 @@ export default function Albums() {
libraryEntityKey(a)} + itemKey={(a, _i) => a.id} rowVariant="album" disableVirtualization={albumBrowsePlainLayout} layoutSignal={displayAlbums.length} @@ -577,7 +566,7 @@ export default function Albums() { observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined} selectionMode={selectionMode} - selected={selectedIds.has(libraryEntityKey(a))} + selected={selectedIds.has(a.id)} onToggleSelect={toggleSelect} selectedAlbums={selectedAlbums} /> diff --git a/src/features/album/pages/LabelAlbums.tsx b/src/features/album/pages/LabelAlbums.tsx index 65d5676e..aa1bde48 100644 --- a/src/features/album/pages/LabelAlbums.tsx +++ b/src/features/album/pages/LabelAlbums.tsx @@ -9,8 +9,6 @@ import { useAuthStore } from '@/store/authStore'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { albumGridWarmCovers } from '@/cover/layoutSizes'; import { VirtualCardGrid } from '@/ui/VirtualCardGrid'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; -import { runLocalBrowseAlbums } from '@/lib/library/browseTextSearch'; export default function LabelAlbums() { const { t } = useTranslation(); @@ -20,8 +18,6 @@ export default function LabelAlbums() { const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId; useEffect(() => { if (!name) return; @@ -30,25 +26,21 @@ export default function LabelAlbums() { setLoading(true); // Search for the label name and ask for a large number of albums - const loadAlbums = browseScope.multiServer - ? runLocalBrowseAlbums(browseServerId, name, 200, false, browseScope.pairs) - .then(rows => rows ?? []) - : search(name, { albumCount: 200, artistCount: 0, songCount: 0 }).then(res => res.albums); - loadAlbums + search(name, { albumCount: 200, artistCount: 0, songCount: 0 }) .then(res => { // Filter out albums that don't match the record label exactly if possible, // to avoid unrelated search hits. We do case-insensitive comparison. - const matches = res.filter(a => + const matches = res.albums.filter(a => a.recordLabel?.toLowerCase() === name.toLowerCase() ); // Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field // (or it's not indexed exactly as typed), just show all album matches // as a decent best-effort if our strict filter yields nothing. - setAlbums(matches.length > 0 ? matches : res); + setAlbums(matches.length > 0 ? matches : res.albums); }) .catch(console.error) .finally(() => setLoading(false)); - }, [name, musicLibraryFilterVersion, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId]); + }, [name, musicLibraryFilterVersion]); return (
diff --git a/src/features/album/pages/LosslessAlbums.tsx b/src/features/album/pages/LosslessAlbums.tsx index 4ddba0b9..2533b320 100644 --- a/src/features/album/pages/LosslessAlbums.tsx +++ b/src/features/album/pages/LosslessAlbums.tsx @@ -1,4 +1,4 @@ -import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl'; +import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl'; import { resolveAlbum } from '@/features/offline'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { songToTrack } from '@/lib/media/songToTrack'; @@ -13,7 +13,6 @@ import { useDownloadModalStore } from '@/features/offline'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useZipDownloadStore } from '@/features/offline'; import { useRangeSelection } from '@/lib/hooks/useRangeSelection'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; import { useMainstageInpageHeaderTight } from '@/lib/hooks/useMainstageInpageHeaderTight'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { showToast } from '@/lib/dom/toast'; @@ -40,7 +39,6 @@ import { sortSubsonicAlbums, type AlbumBrowseSort, } from '@/lib/library/browseTextSearch'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; /** Local index page size — SQLite is cheap; larger pages than the network walk. */ const LOCAL_PAGE_SIZE = 30; @@ -61,8 +59,6 @@ export default function LosslessAlbums() { const auth = useAuthStore(); const activeServerId = useAuthStore(s => s.activeServerId); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || serverId; const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId)); const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort); @@ -84,7 +80,7 @@ export default function LosslessAlbums() { }, [albums, sort, useLocalIndex]); const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums); - const selectedAlbums = displayAlbums.filter(a => selectedIds.has(libraryEntityKey(a))); + const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id)); const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); }; const clearSelection = () => { setSelectionMode(false); resetSelection(); }; @@ -129,18 +125,17 @@ export default function LosslessAlbums() { const loadMoreLocal = useCallback(async () => { const data = await runLocalAlbumBrowsePage( - browseServerId, + serverId, sort, localOffset.current, LOCAL_PAGE_SIZE, undefined, true, - browseScope.pairs, ); if (data == null) return null; localOffset.current += data.length; return { albums: data, hasMore: data.length === LOCAL_PAGE_SIZE }; - }, [browseScope.pairs, browseServerId, sort]); + }, [serverId, sort]); const loadMore = useCallback(async () => { if (inFlight.current || useLocalIndex === null) return; @@ -192,13 +187,12 @@ export default function LosslessAlbums() { try { if (indexEnabled && serverId) { const data = await runLocalAlbumBrowsePage( - browseServerId, + serverId, sort, 0, LOCAL_PAGE_SIZE, undefined, true, - browseScope.pairs, ); if (cancelled) return; if (data != null) { @@ -211,12 +205,6 @@ export default function LosslessAlbums() { } if (cancelled) return; - if (browseScope.multiServer) { - setUseLocalIndex(true); - setAlbums([]); - setHasMore(false); - return; - } setUseLocalIndex(false); const page = await loadMoreNetwork(albums => { if (!cancelled) setAlbums(prev => [...prev, ...albums]); @@ -236,7 +224,7 @@ export default function LosslessAlbums() { })(); return () => { cancelled = true; }; - }, [activeServerId, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId, indexEnabled, loadMoreNetwork, serverId, sort]); + }, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]); const bindLoadMoreSentinel = useInpageScrollSentinel({ active: hasMore && useLocalIndex !== null, @@ -250,7 +238,7 @@ export default function LosslessAlbums() { if (selectedAlbums.length === 0) return; try { const results = await Promise.all( - selectedAlbums.map(a => resolveAlbum(a.serverId ?? serverId, a.id).catch(() => null)), + selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)), ); const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []); if (tracks.length > 0) { @@ -267,10 +255,9 @@ export default function LosslessAlbums() { let queued = 0; for (const album of selectedAlbums) { try { - const ownerServerId = album.serverId ?? serverId; - const detail = await resolveAlbum(ownerServerId, album.id); + const detail = await resolveAlbum(serverId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); @@ -290,8 +277,7 @@ export default function LosslessAlbums() { const downloadId = crypto.randomUUID(); const filename = `${sanitizeFilename(album.name)}.zip`; const destPath = await join(folder, filename); - const ownerServerId = album.serverId ?? serverId; - const url = buildDownloadUrlForServer(ownerServerId, album.id); + const url = buildDownloadUrl(album.id); start(downloadId, filename); try { await downloadZip({ id: downloadId, url, destPath }); @@ -390,7 +376,7 @@ export default function LosslessAlbums() { <> libraryEntityKey(a)} + itemKey={(a, _i) => a.id} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={displayAlbums.length} @@ -402,7 +388,7 @@ export default function LosslessAlbums() { observeScrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} linkQuery={LOSSLESS_MODE_QUERY} selectionMode={selectionMode} - selected={selectedIds.has(libraryEntityKey(a))} + selected={selectedIds.has(a.id)} onToggleSelect={toggleSelect} selectedAlbums={selectedAlbums} /> diff --git a/src/features/album/pages/MostPlayed.tsx b/src/features/album/pages/MostPlayed.tsx index 4accf105..16b65901 100644 --- a/src/features/album/pages/MostPlayed.tsx +++ b/src/features/album/pages/MostPlayed.tsx @@ -14,10 +14,6 @@ import { useLongPressAction } from '@/lib/hooks/useLongPressAction'; import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay'; import { useTranslation } from 'react-i18next'; import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; -import { libraryScopeMostPlayedAlbums } from '@/lib/api/library'; -import { albumToAlbum } from '@/lib/library/advancedSearchLocal'; -import { appendServerQuery } from '@/lib/navigation/detailServerScope'; const PAGE_SIZE = 50; @@ -64,11 +60,11 @@ function formatPlays(n: number, t: ReturnType playAlbum(albumId, { serverId }), - onLongPress: () => playAlbumShuffled(albumId, { serverId }), + onShortPress: () => playAlbum(albumId), + onLongPress: () => playAlbumShuffled(albumId), }); return ( @@ -94,16 +90,13 @@ export default function MostPlayed() { const navigate = useNavigate(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const activeServerId = useAuthStore(s => s.activeServerId); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || activeServerId || ''; const openContextMenu = usePlayerStore(s => s.openContextMenu); const enqueue = usePlayerStore(s => s.enqueue); - const handleEnqueueAlbum = useCallback(async (albumId: string, ownerServerId?: string) => { - const resolvedServerId = ownerServerId ?? activeServerId; - if (!resolvedServerId) return; + const handleEnqueueAlbum = useCallback(async (albumId: string) => { + if (!activeServerId) return; try { - const data = await resolveAlbum(resolvedServerId, albumId); + const data = await resolveAlbum(activeServerId, albumId); if (!data) return; enqueue(data.songs.map(songToTrack)); } catch { @@ -125,13 +118,7 @@ export default function MostPlayed() { setAlbums([]); setHasMore(true); try { - const result = browseScope.pairs.length - ? (await libraryScopeMostPlayedAlbums(browseServerId, { - scopes: browseScope.pairs, - limit: PAGE_SIZE, - offset: 0, - })).map(row => ({ ...albumToAlbum(row.album), playCount: row.playCount })) - : await getAlbumList('frequent', PAGE_SIZE, 0); + const result = await getAlbumList('frequent', PAGE_SIZE, 0); setAlbums(result); setHasMore(result.length === PAGE_SIZE); } catch { /* ignore: best-effort */ } @@ -140,7 +127,7 @@ export default function MostPlayed() { // reads the active library filter internally, so `load` must refresh (and the // mount effect re-run) when that version bumps even though it is unused here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [musicLibraryFilterVersion, browseScope.fingerprint, browseScope.pairs, browseServerId]); + }, [musicLibraryFilterVersion]); // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -150,13 +137,7 @@ export default function MostPlayed() { if (loadingMore || !hasMore) return; setLoadingMore(true); try { - const result = browseScope.pairs.length - ? (await libraryScopeMostPlayedAlbums(browseServerId, { - scopes: browseScope.pairs, - limit: PAGE_SIZE, - offset: albums.length, - })).map(row => ({ ...albumToAlbum(row.album), playCount: row.playCount })) - : await getAlbumList('frequent', PAGE_SIZE, albums.length); + const result = await getAlbumList('frequent', PAGE_SIZE, albums.length); setAlbums(prev => [...prev, ...result]); setHasMore(result.length === PAGE_SIZE); } catch { /* ignore: best-effort */ } @@ -209,7 +190,7 @@ export default function MostPlayed() {
- +
diff --git a/src/lib/library/loadArtistDetailMultiScope.test.ts b/src/features/artist/hooks/loadArtistDetailMultiScope.test.ts similarity index 87% rename from src/lib/library/loadArtistDetailMultiScope.test.ts rename to src/features/artist/hooks/loadArtistDetailMultiScope.test.ts index 746897e9..5316d472 100644 --- a/src/lib/library/loadArtistDetailMultiScope.test.ts +++ b/src/features/artist/hooks/loadArtistDetailMultiScope.test.ts @@ -5,6 +5,10 @@ const libraryScopeArtistDetailMock = vi.fn(); vi.mock('@/lib/api/library/scopeReads', () => ({ libraryScopeArtistDetail: (...args: unknown[]) => libraryScopeArtistDetailMock(...args), + scopePairsFromLibrarySelection: (serverId: string) => [ + { serverId: `${serverId}-idx`, libraryId: 'lib-a' }, + { serverId: `${serverId}-idx`, libraryId: 'lib-b' }, + ], })); import { tryLoadArtistDetailMultiScope } from './loadArtistDetailMultiScope'; @@ -67,10 +71,7 @@ describe('tryLoadArtistDetailMultiScope', () => { ], }); - const result = await tryLoadArtistDetailMultiScope('srv-1', 'art-1', [ - { serverId: 'srv-1-idx', libraryId: 'lib-a' }, - { serverId: 'srv-1-idx', libraryId: 'lib-b' }, - ]); + const result = await tryLoadArtistDetailMultiScope('srv-1', 'art-1'); expect(libraryScopeArtistDetailMock).toHaveBeenCalledWith('srv-1', { scopes: [ @@ -92,12 +93,12 @@ describe('tryLoadArtistDetailMultiScope', () => { tracks: [], }); - await expect(tryLoadArtistDetailMultiScope('srv-1', 'art-1', [])).resolves.toBeNull(); + await expect(tryLoadArtistDetailMultiScope('srv-1', 'art-1')).resolves.toBeNull(); }); it('returns null when the scope command throws', async () => { libraryScopeArtistDetailMock.mockRejectedValue(new Error('ipc fail')); - await expect(tryLoadArtistDetailMultiScope('srv-1', 'art-1', [])).resolves.toBeNull(); + await expect(tryLoadArtistDetailMultiScope('srv-1', 'art-1')).resolves.toBeNull(); }); }); diff --git a/src/lib/library/loadArtistDetailMultiScope.ts b/src/features/artist/hooks/loadArtistDetailMultiScope.ts similarity index 91% rename from src/lib/library/loadArtistDetailMultiScope.ts rename to src/features/artist/hooks/loadArtistDetailMultiScope.ts index 45358ecf..d1a935f8 100644 --- a/src/lib/library/loadArtistDetailMultiScope.ts +++ b/src/features/artist/hooks/loadArtistDetailMultiScope.ts @@ -1,7 +1,7 @@ import { libraryScopeArtistDetail, + scopePairsFromLibrarySelection, } from '@/lib/api/library/scopeReads'; -import type { LibraryScopePair } from '@/lib/api/library'; import { albumToAlbum, artistToArtist, trackToSong } from '@/lib/library/advancedSearchLocal'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes'; @@ -18,11 +18,10 @@ export interface ArtistDetailMultiScopePayload { export async function tryLoadArtistDetailMultiScope( serverId: string, artistId: string, - scopes: LibraryScopePair[], ): Promise { try { const response = await libraryScopeArtistDetail(serverId, { - scopes, + scopes: scopePairsFromLibrarySelection(serverId), artistId, serverId, }); diff --git a/src/features/artist/hooks/useArtistDetailData.multiScope.test.ts b/src/features/artist/hooks/useArtistDetailData.multiScope.test.ts index 47f0ace0..4823c7f3 100644 --- a/src/features/artist/hooks/useArtistDetailData.multiScope.test.ts +++ b/src/features/artist/hooks/useArtistDetailData.multiScope.test.ts @@ -6,22 +6,11 @@ import { useAuthStore } from '@/store/authStore'; const tryLoadArtistDetailMultiScopeMock = vi.fn(); const librarySelectionForServerMock = vi.fn(); -let browseScope = { - pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }], - fingerprint: 'multi', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1', 'srv-2'], - multiServer: true, -}; -vi.mock('@/lib/library/loadArtistDetailMultiScope', () => ({ +vi.mock('@/features/artist/hooks/loadArtistDetailMultiScope', () => ({ tryLoadArtistDetailMultiScope: (...args: unknown[]) => tryLoadArtistDetailMultiScopeMock(...args), })); -vi.mock('@/store/useBrowseLibraryScope', () => ({ - useBrowseLibraryScope: () => browseScope, -})); - vi.mock('@/lib/api/subsonicClient', async importOriginal => { const actual = await importOriginal(); return { @@ -56,13 +45,6 @@ describe('useArtistDetailData — multi-library selection', () => { beforeEach(() => { tryLoadArtistDetailMultiScopeMock.mockReset(); librarySelectionForServerMock.mockReset(); - browseScope = { - pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }], - fingerprint: 'multi', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1', 'srv-2'], - multiServer: true, - }; vi.mocked(getTopSongs).mockResolvedValue([]); vi.mocked(getArtistInfo).mockResolvedValue({} as Awaited>); vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] }); @@ -91,7 +73,7 @@ describe('useArtistDetailData — multi-library selection', () => { const { result } = renderHook(() => useArtistDetailData('art-1'), { wrapper: routerWrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1', browseScope.pairs); + expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1'); expect(getArtistForServer).not.toHaveBeenCalled(); expect(getArtist).not.toHaveBeenCalled(); expect(result.current.artist).toMatchObject({ id: 'art-1', name: 'Merged' }); @@ -110,19 +92,12 @@ describe('useArtistDetailData — multi-library selection', () => { const { result } = renderHook(() => useArtistDetailData('art-1'), { wrapper: routerWrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1', browseScope.pairs); + expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1'); expect(getArtistForServer).not.toHaveBeenCalled(); expect(result.current.albums).toHaveLength(1); }); it('does not call tryLoadArtistDetailMultiScope when all libraries are selected', async () => { - browseScope = { - pairs: [], - fingerprint: 'single', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1'], - multiServer: false, - }; librarySelectionForServerMock.mockReturnValue([]); vi.mocked(getArtistForServer).mockResolvedValue({ artist: { id: 'art-1', name: 'Network' }, @@ -139,13 +114,6 @@ describe('useArtistDetailData — multi-library selection', () => { }); it('falls back to the local library index when network getArtist fails', async () => { - browseScope = { - pairs: [], - fingerprint: 'single', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1'], - multiServer: false, - }; // Random Albums links an album-artist id that `getArtist` 404s on, but the // artist row exists in the local index the album came from → resolve there // instead of showing "Artist not found". @@ -166,13 +134,6 @@ describe('useArtistDetailData — multi-library selection', () => { }); it('shows nothing to resolve when both network and local index miss', async () => { - browseScope = { - pairs: [], - fingerprint: 'single', - anchorServerId: 'srv-1', - configuredServerIds: ['srv-1'], - multiServer: false, - }; librarySelectionForServerMock.mockReturnValue([]); vi.mocked(getArtistForServer).mockRejectedValue(new Error('artist not found')); vi.mocked(loadArtistFromLibraryIndex).mockResolvedValue(null); @@ -184,7 +145,7 @@ describe('useArtistDetailData — multi-library selection', () => { expect(result.current.artist).toBeNull(); }); - it('does not fall through to getArtist when multi-server scope load returns null', async () => { + it('falls through to getArtist when multi-scope load returns null', async () => { librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']); tryLoadArtistDetailMultiScopeMock.mockResolvedValue(null); vi.mocked(getArtistForServer).mockResolvedValue({ @@ -196,7 +157,7 @@ describe('useArtistDetailData — multi-library selection', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalled(); - expect(getArtistForServer).not.toHaveBeenCalled(); - expect(result.current.artist).toBeNull(); + expect(getArtistForServer).toHaveBeenCalled(); + expect(result.current.artist).toMatchObject({ name: 'Fallback' }); }); }); diff --git a/src/features/artist/hooks/useArtistDetailData.ts b/src/features/artist/hooks/useArtistDetailData.ts index e08f68cd..7533c999 100644 --- a/src/features/artist/hooks/useArtistDetailData.ts +++ b/src/features/artist/hooks/useArtistDetailData.ts @@ -13,8 +13,8 @@ import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '@/featur import { readDetailServerId } from '@/lib/navigation/detailServerScope'; import { runLocalArtistLosslessBrowse } from '@/lib/library/browseTextSearch'; import { isLosslessSuffix } from '@/lib/library/losslessFormats'; -import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; +import { librarySelectionForServer } from '@/lib/api/subsonicClient'; +import { tryLoadArtistDetailMultiScope } from '@/features/artist/hooks/loadArtistDetailMultiScope'; export interface UseArtistDetailDataOptions { /** When true, albums and top tracks are limited to lossless containers (local index preferred). */ @@ -56,7 +56,6 @@ export function useArtistDetailData( const activeServerId = useAuthStore(s => s.activeServerId); const [searchParams] = useSearchParams(); const serverId = readDetailServerId(searchParams, activeServerId); - const browseScope = useBrowseLibraryScope(); const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); const { status: connStatus } = useConnectionStatus(); const audiomuseNavidromeEnabled = useAuthStore( @@ -95,21 +94,14 @@ export function useArtistDetailData( setLoading(false); return; } - if (serverId && browseScope.pairs.length > 0) { - const multi = await tryLoadArtistDetailMultiScope(serverId, id, browseScope.pairs); + if (serverId && librarySelectionForServer(serverId).length > 0) { + const multi = await tryLoadArtistDetailMultiScope(serverId, id); if (cancelled) return; if (multi) { - const scoped = losslessOnly - ? filterNetworkArtistToLossless(multi.albums, multi.topSongs) - : { albums: multi.albums, songs: multi.topSongs }; setArtist(multi.artist); setIsStarred(!!multi.artist.starred); - setAlbums(scoped.albums); - setTopSongs(scoped.songs); - setLoading(false); - return; - } - if (browseScope.multiServer) { + setAlbums(multi.albums); + setTopSongs(multi.topSongs); setLoading(false); return; } @@ -201,9 +193,6 @@ export function useArtistDetailData( return () => { cancelled = true; }; }, [ id, - browseScope.fingerprint, - browseScope.multiServer, - browseScope.pairs, losslessOnly, musicLibraryFilterVersion, musicLibrarySelectionByServer, @@ -215,7 +204,7 @@ export function useArtistDetailData( ]); useEffect(() => { - if (!id || preferLocalArtist || browseScope.multiServer) return; + if (!id || preferLocalArtist) return; let cancelled = false; // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -231,10 +220,10 @@ export function useArtistDetailData( if (!cancelled) setArtistInfoLoading(false); }); return () => { cancelled = true; }; - }, [id, audiomuseNavidromeEnabled, browseScope.multiServer, preferLocalArtist]); + }, [id, audiomuseNavidromeEnabled, preferLocalArtist]); useEffect(() => { - if (!id || !artist || preferLocalArtist || browseScope.multiServer) return; + if (!id || !artist || preferLocalArtist) return; const ownAlbumIds = new Set(albums.map(a => a.id)); // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -276,7 +265,7 @@ export function useArtistDetailData( setFeaturedLoading(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [artist?.id, browseScope.multiServer, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]); + }, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]); const info = infoEntry && infoEntry.id === id ? infoEntry.value : null; diff --git a/src/features/artist/hooks/useArtistsBrowseCatalog.ts b/src/features/artist/hooks/useArtistsBrowseCatalog.ts index 7b44a555..c6f44f71 100644 --- a/src/features/artist/hooks/useArtistsBrowseCatalog.ts +++ b/src/features/artist/hooks/useArtistsBrowseCatalog.ts @@ -18,7 +18,6 @@ import { offlineLocalBrowseEnabled, } from '@/features/offline'; import { librarySelectionForServer } from '@/lib/api/subsonicClient'; -import type { LibraryScopePair } from '@/lib/api/library'; import { scheduleAlbumBrowseBackgroundWork } from '@/lib/library/albumBrowseBackground'; import { artistBrowseTimed, @@ -51,9 +50,6 @@ export type UseArtistsBrowseCatalogArgs = { libraryScopeKey: string; /** Server `ignoredArticles` for offline letter buckets (Navidrome parity). */ ignoredArticles?: string | null; - scopePairs?: LibraryScopePair[]; - scopeFingerprint?: string; - localOnly?: boolean; }; export function useArtistsBrowseCatalog({ @@ -65,9 +61,6 @@ export function useArtistsBrowseCatalog({ musicLibraryFilterVersion, libraryScopeKey, ignoredArticles, - scopePairs, - scopeFingerprint = '', - localOnly = false, }: UseArtistsBrowseCatalogArgs) { const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); @@ -91,7 +84,7 @@ export function useArtistsBrowseCatalog({ const base = artistBrowseInitialLoadKey( serverId, musicLibraryFilterVersion, - `${libraryScopeKey}\0${scopeFingerprint}`, + libraryScopeKey, creditMode, letterFilter, starredOnly, @@ -102,7 +95,7 @@ export function useArtistsBrowseCatalog({ // resync surfaces renamed/pruned artists without an app restart. if (!offlineBrowseActive) return artistBrowseOnlineCatalogKey(base, librarySyncRevision); return `${base}\0${offlineLocalBrowseReloadKey}`; - }, [serverId, musicLibraryFilterVersion, libraryScopeKey, scopeFingerprint, creditMode, letterFilter, starredOnly, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision]); + }, [serverId, musicLibraryFilterVersion, libraryScopeKey, creditMode, letterFilter, starredOnly, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision]); useLayoutEffect(() => { const cached = readArtistBrowseCatalogCache(catalogLoadKey); @@ -173,10 +166,8 @@ export function useArtistsBrowseCatalog({ serverId, catalogOffsetRef.current, ARTIST_CATALOG_CHUNK_SIZE, - creditMode, - letterFilter, - scopePairs, - starredOnly, + creditMode, + letterFilter, ), { append, offset: catalogOffsetRef.current, creditMode, letterFilter }, ); @@ -209,7 +200,7 @@ export function useArtistsBrowseCatalog({ setCatalogLoadingMore(false); } } - }, [creditMode, ignoredArticles, letterFilter, offlineBrowseActive, scopePairs, serverId, starredOnly]); + }, [creditMode, ignoredArticles, letterFilter, offlineBrowseActive, serverId]); useEffect(() => { let cancelled = false; @@ -296,7 +287,7 @@ export function useArtistsBrowseCatalog({ } return; } - if (starredOnly && !localOnly) { + if (starredOnly) { emitArtistsBrowseDebug('load_branch', { mode: 'starred' }); if (!cancelled && generation === loadGenerationRef.current) { const starred = await artistBrowseTimed( @@ -326,9 +317,7 @@ export function useArtistsBrowseCatalog({ 0, ARTIST_BROWSE_BOOTSTRAP_CHUNK, creditMode, - letterFilter, - scopePairs, - false, + letterFilter, ), { creditMode, letterFilter, chunkSize: ARTIST_BROWSE_BOOTSTRAP_CHUNK }, ), @@ -367,9 +356,7 @@ export function useArtistsBrowseCatalog({ tailOffset, tailSize, creditMode, - letterFilter, - scopePairs, - false, + letterFilter, ), { creditMode, letterFilter, chunkSize: tailSize, offset: tailOffset }, ), @@ -406,8 +393,6 @@ export function useArtistsBrowseCatalog({ ARTIST_CATALOG_CHUNK_SIZE, creditMode, letterFilter, - scopePairs, - starredOnly, ), { creditMode, letterFilter, chunkSize: ARTIST_CATALOG_CHUNK_SIZE }, ), @@ -428,7 +413,7 @@ export function useArtistsBrowseCatalog({ } emitArtistsBrowseDebug('slice_fallback', { reason: 'local_chunk_null' }); } - if (!cancelled && generation === loadGenerationRef.current && !indexEnabled && !localOnly) { + if (!cancelled && generation === loadGenerationRef.current && !indexEnabled) { emitArtistsBrowseDebug('load_branch', { mode: 'network' }); const network = await artistBrowseTimed( 'network_catalog', @@ -470,7 +455,7 @@ export function useArtistsBrowseCatalog({ return () => { cancelled = true; }; - }, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, localOnly, offlineBrowseActive, offlineBrowseReloadTs, scopePairs, serverId, starredOnly]); + }, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]); return { catalogArtists, diff --git a/src/features/artist/hooks/useBrowseArtistTextSearch.ts b/src/features/artist/hooks/useBrowseArtistTextSearch.ts index 55d9b6af..7dd1189d 100644 --- a/src/features/artist/hooks/useBrowseArtistTextSearch.ts +++ b/src/features/artist/hooks/useBrowseArtistTextSearch.ts @@ -16,7 +16,6 @@ import { artistBrowseTimed, emitArtistsBrowseDebug, } from '@/lib/library/artistBrowseDebug'; -import type { LibraryScopePair } from '@/lib/api/library'; /** * Debounced artist/composer name search with local-vs-network race when the @@ -31,8 +30,6 @@ export function useBrowseArtistTextSearch( surface: LibrarySearchSurface = 'artists_browse', creditMode: ArtistCreditMode = 'album', starredOnly = false, - scopePairs?: LibraryScopePair[], - localOnly = false, ) { const offlineBrowseActive = useOfflineBrowseContext().active; const [debouncedFilter, setDebouncedFilter] = useState(''); @@ -48,7 +45,7 @@ export function useBrowseArtistTextSearch( useEffect(() => { const q = debouncedFilter; - if (starredOnly || !q || (!indexEnabled && !localOnly) || !serverId) { + if (starredOnly || !q || !indexEnabled || !serverId) { // React Compiler set-state-in-effect rule: state set from a timer/animation callback. // eslint-disable-next-line react-hooks/set-state-in-effect setTextSearchArtists(null); @@ -76,26 +73,11 @@ export function useBrowseArtistTextSearch( emitArtistsBrowseDebug('text_search_done', { source: 'offline', artistCount: artists?.length ?? 0 }); return; } - if (localOnly) { - const artists = await artistBrowseTimed( - 'text_search_local', - () => runLocalBrowseArtists(serverId, q, creditMode, undefined, scopePairs), - { query: q, creditMode }, - ); - if (isStale()) return; - setTextSearchArtists(artists ?? []); - setTextSearchLoading(false); - emitArtistsBrowseDebug('text_search_done', { - source: 'local', - artistCount: artists?.length ?? 0, - }); - return; - } const outcome = await artistBrowseTimed( 'text_search_race', () => raceBrowseWithLocalFallback( isStale, - () => runLocalBrowseArtists(serverId, q, creditMode, undefined, scopePairs), + () => runLocalBrowseArtists(serverId, q, creditMode), () => runNetworkBrowseArtists(q, creditMode), { surface, @@ -114,7 +96,7 @@ export function useBrowseArtistTextSearch( artistCount: outcome?.result?.length ?? 0, }); })(); - }, [creditMode, debouncedFilter, indexEnabled, localOnly, offlineBrowseActive, scopePairs, serverId, starredOnly, surface]); + }, [creditMode, debouncedFilter, indexEnabled, offlineBrowseActive, serverId, starredOnly, surface]); const effectiveFilter = textSearchArtists != null ? '' : filter; return { textSearchArtists, textSearchLoading, effectiveFilter }; diff --git a/src/features/artist/pages/Artists.tsx b/src/features/artist/pages/Artists.tsx index 7b8d6917..5ba2872b 100644 --- a/src/features/artist/pages/Artists.tsx +++ b/src/features/artist/pages/Artists.tsx @@ -47,18 +47,12 @@ import { beginArtistsBrowseTrace, emitArtistsBrowseDebug, } from '@/lib/library/artistBrowseDebug'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; -import { appendServerQuery } from '@/lib/navigation/detailServerScope'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; export default function Artists() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || serverId; - const sessionScopeKey = `${serverId}\0${browseScope.fingerprint}`; const libraryScopeKey = useAuthStore(s => { if (!serverId) return 'all'; const resolved = resolveServerIdForIndexKey(serverId); @@ -74,7 +68,7 @@ export default function Artists() { const scrollSnapshotRef = useRef({ scrollTop: 0, visibleCount: 0 }); const restoreVisibleCountRef = useRef( - peekArtistBrowseScrollRestore(sessionScopeKey)?.visibleCount, + peekArtistBrowseScrollRestore(serverId)?.visibleCount, ); const { @@ -86,7 +80,7 @@ export default function Artists() { setCreditMode, viewMode, setViewMode, - } = useArtistsBrowseFilters(sessionScopeKey, scrollSnapshotRef); + } = useArtistsBrowseFilters(serverId, scrollSnapshotRef); useLayoutEffect(() => { beginArtistsBrowseTrace({ @@ -127,7 +121,7 @@ export default function Artists() { loadCatalogChunk, catalogLoadingRef, } = useArtistsBrowseCatalog({ - serverId: browseServerId, + serverId, indexEnabled, starredOnly, creditMode, @@ -135,20 +129,15 @@ export default function Artists() { musicLibraryFilterVersion, libraryScopeKey, ignoredArticles, - scopePairs: browseScope.pairs, - scopeFingerprint: browseScope.fingerprint, - localOnly: browseScope.multiServer, }); const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( artistsSearchQuery, indexEnabled, - browseServerId, + serverId, 'artists_browse', creditMode, starredOnly, - browseScope.pairs, - browseScope.multiServer, ); const artists = starredOnly ? catalogArtists : (textSearchArtists ?? catalogArtists); const loading = starredOnly ? catalogLoading : (catalogLoading || textSearchLoading); @@ -165,7 +154,7 @@ export default function Artists() { loadMore: sliceLoadMore, } = useClientSliceInfiniteScroll({ pageSize: PAGE_SIZE, - resetDeps: [artistsSearchQuery, letterFilter, starredOnly, creditMode, viewMode, musicLibraryFilterVersion, browseScope.fingerprint], + resetDeps: [artistsSearchQuery, letterFilter, starredOnly, creditMode, viewMode, musicLibraryFilterVersion, serverId], getScrollRoot: getArtistsScrollRoot, scrollRootEl: artistsScrollBodyEl, restoreDisplayCount: restoreVisibleCountRef.current, @@ -180,8 +169,7 @@ export default function Artists() { setSelectedIds(new Set()); }; - const toggleSelect = useCallback((artist: Parameters[0]) => { - const id = libraryEntityKey(artist); + const toggleSelect = useCallback((id: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); @@ -189,11 +177,7 @@ export default function Artists() { }); }, []); - const selectedArtists = artists.filter(a => selectedIds.has(libraryEntityKey(a))); - const openArtist = useCallback((artist: Parameters[0]) => { - const search = appendServerQuery(undefined, artist.serverId ?? undefined); - navigateToArtist(artist.id, search ? { search } : undefined); - }, [navigateToArtist]); + const selectedArtists = artists.filter(a => selectedIds.has(a.id)); const { filtered, visible, hasMore, groups, letters, artistListFlatRows, @@ -533,7 +517,7 @@ export default function Artists() { selectedArtists={selectedArtists} showArtistImages={showArtistImages} toggleSelect={toggleSelect} - onOpenArtist={openArtist} + onOpenArtist={navigateToArtist} openContextMenu={openContextMenu} t={t} /> @@ -553,7 +537,7 @@ export default function Artists() { selectedArtists={selectedArtists} showArtistImages={showArtistImages} toggleSelect={toggleSelect} - onOpenArtist={openArtist} + onOpenArtist={navigateToArtist} openContextMenu={openContextMenu} t={t} /> diff --git a/src/features/artist/store/artistBrowseSessionStore.ts b/src/features/artist/store/artistBrowseSessionStore.ts index d9d97828..5550782d 100644 --- a/src/features/artist/store/artistBrowseSessionStore.ts +++ b/src/features/artist/store/artistBrowseSessionStore.ts @@ -32,20 +32,15 @@ interface ArtistBrowseSessionStore { peekReturnStash: (serverId: string) => ArtistBrowseReturnState | null; } -function returnStashKey(scopeKey: string): string { - return JSON.stringify([scopeKey]); -} - export const useArtistBrowseSessionStore = create((set, get) => ({ returnStashByServer: {}, stashReturnState: (serverId, state) => { if (!serverId) return; - const key = returnStashKey(serverId); set((s) => ({ returnStashByServer: { ...s.returnStashByServer, - [key]: { + [serverId]: { filter: state.filter, letterFilter: state.letterFilter, starredOnly: state.starredOnly, @@ -62,13 +57,13 @@ export const useArtistBrowseSessionStore = create((set clearReturnStash: (serverId) => { if (!serverId) return; const next = { ...get().returnStashByServer }; - delete next[returnStashKey(serverId)]; + delete next[serverId]; set({ returnStashByServer: next }); }, peekReturnStash: (serverId) => { if (!serverId) return null; - const stash = get().returnStashByServer[returnStashKey(serverId)]; + const stash = get().returnStashByServer[serverId]; if (!stash) return null; return { filter: stash.filter, diff --git a/src/features/artist/utils/runArtistDetailActions.ts b/src/features/artist/utils/runArtistDetailActions.ts index 1e1d88e1..61450860 100644 --- a/src/features/artist/utils/runArtistDetailActions.ts +++ b/src/features/artist/utils/runArtistDetailActions.ts @@ -1,8 +1,9 @@ import type React from 'react'; import type { TFunction } from 'i18next'; import { uploadArtistImage } from '@/lib/api/subsonicArtists'; -import { queueEntityRating, queueEntityStar } from '@/features/playback'; +import { setRating, star, unstar } from '@/lib/api/subsonicStarRating'; import type { SubsonicArtist } from '@/lib/api/subsonicTypes'; +import { useAuthStore } from '@/store/authStore'; import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink'; import { invalidateCoverArt } from '@/cover'; import { showToast } from '@/lib/dom/toast'; @@ -19,15 +20,27 @@ export interface RunArtistEntityRatingDeps { } export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise { - const { artist, id, rating, artistEntityRatingSupport, activeServerId, setArtistEntityRating, setArtist } = deps; + const { artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist } = deps; if (!artist || artist.id !== id) return; const artistId = artist.id; + const ratingAtStart = artist.userRating ?? 0; + setArtistEntityRating(rating); if (artistEntityRatingSupport !== 'full') return; - queueEntityRating('artist', artistId, rating, artist.serverId ?? activeServerId); - setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a)); + try { + await setRating(artistId, rating); + setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a)); + } catch (err) { + setArtistEntityRating(ratingAtStart); + useAuthStore.getState().setEntityRatingSupport(activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + } } export interface RunArtistToggleStarDeps { @@ -41,7 +54,18 @@ export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promis if (!artist) return; const currentlyStarred = isStarred; setIsStarred(!currentlyStarred); - queueEntityStar('artist', artist.id, !currentlyStarred, artist.serverId); + try { + const meta = { + serverId: artist.serverId, + name: artist.name, + albumCount: artist.albumCount, + }; + if (currentlyStarred) await unstar(artist.id, 'artist', meta); + else await star(artist.id, 'artist', meta); + } catch (e) { + console.error('Failed to toggle star', e); + setIsStarred(currentlyStarred); + } } export interface RunArtistShareDeps { @@ -52,7 +76,7 @@ export interface RunArtistShareDeps { export async function runArtistShare(deps: RunArtistShareDeps): Promise { const { artist, t } = deps; try { - const ok = await copyEntityShareLink('artist', artist.id, artist.serverId); + const ok = await copyEntityShareLink('artist', artist.id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } catch { diff --git a/src/features/composers/pages/ComposerDetail.tsx b/src/features/composers/pages/ComposerDetail.tsx index 02a7cbb9..3e122e46 100644 --- a/src/features/composers/pages/ComposerDetail.tsx +++ b/src/features/composers/pages/ComposerDetail.tsx @@ -1,4 +1,4 @@ -import { queueEntityStar } from '@/features/playback'; +import { star, unstar } from '@/lib/api/subsonicStarRating'; import { getArtist, getArtistInfo } from '@/lib/api/subsonicArtists'; import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '@/lib/api/subsonicTypes'; import { useEffect, useState, useMemo } from 'react'; @@ -11,6 +11,7 @@ import { useCoverLightboxSrc } from '@/cover/lightbox'; import { ArrowLeft, Users, Heart, Feather, Share2 } from 'lucide-react'; import WikipediaIcon from '@/ui/WikipediaIcon'; import { open } from '@tauri-apps/plugin-shell'; +import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useAuthStore } from '@/store/authStore'; import { useTranslation } from 'react-i18next'; import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink'; @@ -34,6 +35,7 @@ export default function ComposerDetail() { const [headerCoverFailed, setHeaderCoverFailed] = useState(false); const [openedLink, setOpenedLink] = useState(null); + const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); // Subsonic `getArtist.view` only follows AlbumArtist relations, so for a @@ -102,7 +104,20 @@ export default function ComposerDetail() { if (!artist) return; const next = !isStarred; setIsStarred(next); - queueEntityStar('artist', artist.id, next, artist.serverId); + setStarredOverride(artist.id, next); + try { + const meta = { + serverId: artist.serverId, + name: artist.name, + albumCount: artist.albumCount, + }; + if (next) await star(artist.id, 'artist', meta); + else await unstar(artist.id, 'artist', meta); + } catch (err) { + console.warn('[psysonic] composer star failed:', err); + setIsStarred(!next); + setStarredOverride(artist.id, !next); + } }; const openLink = (url: string, key: string) => { @@ -114,7 +129,7 @@ export default function ComposerDetail() { const handleShareComposer = async () => { if (!id || !artist) return; try { - const ok = await copyEntityShareLink('composer', artist.id, artist.serverId); + const ok = await copyEntityShareLink('composer', artist.id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } catch { diff --git a/src/features/composers/pages/Composers.tsx b/src/features/composers/pages/Composers.tsx index aa969238..4ce704bc 100644 --- a/src/features/composers/pages/Composers.tsx +++ b/src/features/composers/pages/Composers.tsx @@ -20,8 +20,6 @@ import { peekComposerBrowseScrollRestore } from '@/features/composers/store/comp import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore'; import { readComposerBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { filterArtistsWithRoleAlbumCredits } from '@/lib/library/composerBrowse'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; -import { libraryScopeListArtistsByRole } from '@/lib/api/library'; import { ALL_SENTINEL, artistLetterBucket } from '@/features/artist'; import { useLibraryIgnoredArticles } from '@/lib/library/hooks/useLibraryIgnoredArticles'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; @@ -90,8 +88,6 @@ export default function Composers() { const scrollSnapshotRef = useRef({ scrollTop: 0, visibleCount: 0 }); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || serverId; const restoreVisibleCountRef = useRef( peekComposerBrowseScrollRestore(serverId)?.visibleCount, ); @@ -156,21 +152,7 @@ export default function Composers() { // an option but Symfonium-style classical libs rarely exceed a few thousand // composers, and a single round-trip beats N infinite-scroll calls when the // list is alphabetised + filtered locally. - const loadComposers = browseScope.multiServer - ? libraryScopeListArtistsByRole(browseServerId, { - scopes: browseScope.pairs, - role: 'composer', - limit: 10000, - }).then(rows => rows.map(row => ({ - id: row.id, - name: row.name, - nameSort: row.nameSort ?? undefined, - albumCount: row.albumCount ?? undefined, - coverArt: row.id, - serverId: row.serverId, - }))) - : ndListArtistsByRole('composer', 0, 10000); - loadComposers + ndListArtistsByRole('composer', 0, 10000) .then(data => { if (cancelled) return; setComposers(filterArtistsWithRoleAlbumCredits(data)); @@ -188,7 +170,7 @@ export default function Composers() { setLoading(false); }); return () => { cancelled = true; }; - }, [musicLibraryFilterVersion, reloadTick, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId]); + }, [musicLibraryFilterVersion, reloadTick]); const starredOverrides = usePlayerStore(s => s.starredOverrides); const ignoredArticles = useLibraryIgnoredArticles(serverId); diff --git a/src/features/contextMenu/components/AddToPlaylistSubmenu.tsx b/src/features/contextMenu/components/AddToPlaylistSubmenu.tsx index c7295294..872688f6 100644 --- a/src/features/contextMenu/components/AddToPlaylistSubmenu.tsx +++ b/src/features/contextMenu/components/AddToPlaylistSubmenu.tsx @@ -6,13 +6,9 @@ import { usePlaylistStore } from '@/features/playlist'; import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist'; import { showToast } from '@/lib/dom/toast'; import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers'; -import type { Track } from '@/lib/media/trackTypes'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; -import { resolvePlaylistTargetTrackIds } from '@/features/playlist'; interface Props { songIds: string[]; - tracks?: Track[]; /** When set (bulk toolbar pickers), read IDs at action time — avoids stale props if selection changes after open. */ resolveSongIds?: () => readonly string[]; onDone: () => void; @@ -20,7 +16,7 @@ interface Props { triggerId?: string; } -export function AddToPlaylistSubmenu({ songIds, tracks, resolveSongIds, onDone, dropDown, triggerId }: Props) { +export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown, triggerId }: Props) { const { t } = useTranslation(); const subRef = useRef(null); const newNameRef = useRef(null); @@ -38,7 +34,6 @@ export function AddToPlaylistSubmenu({ songIds, tracks, resolveSongIds, onDone, const createPlaylist = usePlaylistStore((s) => s.createPlaylist); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists); - const browseScope = useBrowseLibraryScope(); useEffect(() => { if (storePlaylists.length === 0) fetchPlaylists(); @@ -73,15 +68,12 @@ export function AddToPlaylistSubmenu({ songIds, tracks, resolveSongIds, onDone, const idsForAction = () => [...(resolveSongIds?.() ?? songIdsRef.current)]; const handleAdd = async (pl: SubsonicPlaylist) => { - if (!pl.serverId) return; - const ids = tracks?.length - ? await resolvePlaylistTargetTrackIds(pl.serverId, tracks, browseScope.pairs) - : idsForAction(); + const ids = idsForAction(); setAdding(pl.id); try { - const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, ids, t, pl.serverId); + const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, ids, t); showAddTracksDedupToast(t, pl.name, result); - if (result.outcome !== 'skipped') touchPlaylist(pl.id, pl.serverId); + if (result.outcome !== 'skipped') touchPlaylist(pl.id); } catch { showToast(t('playlists.addError'), 3000, 'error'); } @@ -93,8 +85,7 @@ export function AddToPlaylistSubmenu({ songIds, tracks, resolveSongIds, onDone, const ids = idsForAction(); const name = newName.trim() || t('playlists.unnamed'); try { - const targetServerId = storePlaylists[0]?.serverId; - const pl = await createPlaylist(name, ids, targetServerId); + const pl = await createPlaylist(name, ids); if (pl?.id) { showToast(t('playlists.createAndAddSuccess', { count: ids.length, playlist: pl.name || name })); } @@ -153,7 +144,7 @@ export function AddToPlaylistSubmenu({ songIds, tracks, resolveSongIds, onDone, )} {playlists.map((pl: SubsonicPlaylist) => (
handleAdd(pl)} style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }} diff --git a/src/features/contextMenu/components/AlbumContextItems.tsx b/src/features/contextMenu/components/AlbumContextItems.tsx index 86dfa5de..e673c78e 100644 --- a/src/features/contextMenu/components/AlbumContextItems.tsx +++ b/src/features/contextMenu/components/AlbumContextItems.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { resolveAlbum, resolveMediaServerId } from '@/features/offline'; -import { queueEntityStar } from '@/features/playback'; +import { star, unstar } from '@/lib/api/subsonicStarRating'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { songToTrack } from '@/lib/media/songToTrack'; import StarRating from '@/ui/StarRating'; @@ -13,7 +13,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co export default function AlbumContextItems(props: ContextMenuItemsProps) { const { type, item, playNext, enqueue, closeContextMenu, - userRatingOverrides, setKeyboardRating, keyboardRating, + setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, entityRatingSupport, applyAlbumRating, @@ -61,7 +61,16 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { {offlinePolicy.canFavorite && (
handleAction(() => { const starred = isStarred(album.id, album.starred); - queueEntityStar('album', album.id, !starred, album.serverId); + setStarredOverride(album.id, !starred); + const meta = { + serverId: album.serverId, + name: album.name, + artist: album.artist, + artistId: album.artistId, + coverArtId: album.coverArt, + year: album.year, + }; + return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta); })}> {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')} @@ -87,7 +96,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
)}
-
handleAction(() => copyShareLink('album', album.id, album.serverId))}> +
handleAction(() => copyShareLink('album', album.id))}> {t('contextMenu.shareLink')}
{offlinePolicy.canDownload && ( diff --git a/src/features/contextMenu/components/ArtistContextItems.tsx b/src/features/contextMenu/components/ArtistContextItems.tsx index 7ca97b0b..5ef70a80 100644 --- a/src/features/contextMenu/components/ArtistContextItems.tsx +++ b/src/features/contextMenu/components/ArtistContextItems.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { Radio, Heart, ChevronRight, ListMusic, Star, Share2 } from 'lucide-react'; -import { queueEntityStar } from '@/features/playback'; +import { star, unstar } from '@/lib/api/subsonicStarRating'; import type { SubsonicArtist } from '@/lib/api/subsonicTypes'; import StarRating from '@/ui/StarRating'; import { ArtistToPlaylistSubmenu } from '@/features/contextMenu/components/AlbumArtistToPlaylistSubmenu'; @@ -10,7 +10,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co export default function ArtistContextItems(props: ContextMenuItemsProps) { const { type, item, shareKindOverride, closeContextMenu, - userRatingOverrides, setKeyboardRating, keyboardRating, + setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, entityRatingSupport, applyArtistRating, @@ -43,7 +43,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) { )}
)} -
handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id, artist.serverId))}> +
handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}> {t('contextMenu.shareLink')}
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && ( @@ -52,7 +52,15 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) { {offlinePolicy.canFavorite && (
handleAction(() => { const starred = isStarred(artist.id, artist.starred); - queueEntityStar('artist', artist.id, !starred, artist.serverId); + setStarredOverride(artist.id, !starred); + const meta = { + serverId: artist.serverId, + name: artist.name, + albumCount: artist.albumCount, + }; + return starred + ? unstar(artist.id, 'artist', meta) + : star(artist.id, 'artist', meta); })}> {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')} diff --git a/src/features/contextMenu/components/ContextMenu.tsx b/src/features/contextMenu/components/ContextMenu.tsx index 7d9ca723..c75b4a5d 100644 --- a/src/features/contextMenu/components/ContextMenu.tsx +++ b/src/features/contextMenu/components/ContextMenu.tsx @@ -15,7 +15,6 @@ import { } from '@/features/contextMenu/utils/contextMenuActions'; import { useContextMenuKeyboardNav } from '@/features/contextMenu/hooks/useContextMenuKeyboardNav'; import { useContextMenuRating } from '@/features/contextMenu/hooks/useContextMenuRating'; -import { entityOverrideKey } from '@/lib/media/entityOverrideKey'; import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate'; import { useNavigate } from 'react-router-dom'; import { useOfflineBrowseContext } from '@/features/offline'; @@ -178,11 +177,8 @@ export default function ContextMenu() { ? navigatePlaybackLibrary : (path: string) => { navigate(path); }; - const isStarred = (id: string, itemStarred?: string) => { - const itemServerId = (item as { serverId?: string } | null)?.serverId ?? auth.activeServerId; - const key = entityOverrideKey(itemServerId, id); - return key in starredOverrides ? starredOverrides[key] : !!itemStarred; - }; + const isStarred = (id: string, itemStarred?: string) => + id in starredOverrides ? starredOverrides[id] : !!itemStarred; const { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind } = useContextMenuRating({ type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t }); @@ -208,8 +204,7 @@ export default function ContextMenu() { }; const copyShareLink = useCallback( - (kind: EntityShareKind, id: string, representativeServerId?: string) => - copyShareLinkAction(kind, id, t, representativeServerId), + (kind: EntityShareKind, id: string) => copyShareLinkAction(kind, id, t), [t], ); diff --git a/src/features/contextMenu/components/MoveToFolderSubmenu.tsx b/src/features/contextMenu/components/MoveToFolderSubmenu.tsx index 957b3be2..85103424 100644 --- a/src/features/contextMenu/components/MoveToFolderSubmenu.tsx +++ b/src/features/contextMenu/components/MoveToFolderSubmenu.tsx @@ -1,11 +1,11 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Check, Folder, FolderMinus, Plus } from 'lucide-react'; +import { useAuthStore } from '@/store/authStore'; import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/features/playlist'; interface Props { playlistId: string; - serverId?: string; onDone: () => void; triggerId?: string; } @@ -16,7 +16,7 @@ interface Props { * hover machinery. Folder assignment is purely local state, so it stays * available offline. */ -export default function MoveToFolderSubmenu({ playlistId, serverId, onDone, triggerId }: Props) { +export default function MoveToFolderSubmenu({ playlistId, onDone, triggerId }: Props) { const { t } = useTranslation(); const subRef = useRef(null); const [creating, setCreating] = useState(false); @@ -25,6 +25,7 @@ export default function MoveToFolderSubmenu({ playlistId, serverId, onDone, trig const [flipLeft, setFlipLeft] = useState(false); const [flipUp, setFlipUp] = useState(false); + const serverId = useAuthStore(s => s.activeServerId); const bucket = usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS; const createFolder = usePlaylistFolderStore(s => s.createFolder); diff --git a/src/features/contextMenu/components/PlaylistContextItems.tsx b/src/features/contextMenu/components/PlaylistContextItems.tsx index 1318dbcb..317641f3 100644 --- a/src/features/contextMenu/components/PlaylistContextItems.tsx +++ b/src/features/contextMenu/components/PlaylistContextItems.tsx @@ -24,21 +24,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { return ( <>
handleAction(async () => { - const tracks = await resolvePlaylistTracks(playlist.id, playlist.serverId); + const tracks = await resolvePlaylistTracks(playlist.id); if (tracks.length === 0) return; playTrack(tracks[0], tracks); })}> {t('contextMenu.playNow')}
handleAction(async () => { - const tracks = await resolvePlaylistTracks(playlist.id, playlist.serverId); + const tracks = await resolvePlaylistTracks(playlist.id); if (tracks.length === 0) return; playNext(tracks); })}> {t('contextMenu.playNext')}
handleAction(async () => { - const tracks = await resolvePlaylistTracks(playlist.id, playlist.serverId); + const tracks = await resolvePlaylistTracks(playlist.id); if (tracks.length === 0) return; enqueue(tracks); })}> @@ -69,7 +69,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { {t('playlists.folders.moveToFolder')} {playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
{offlinePolicy.canEditPlaylist && ( @@ -77,15 +77,14 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
handleAction(async () => { const { showToast } = await import('@/lib/dom/toast'); - const { deletePlaylistForServer } = await import('@/lib/api/subsonicPlaylists'); + const { deletePlaylist } = await import('@/lib/api/subsonicPlaylists'); const { removeId } = usePlaylistStore.getState(); try { - if (!playlist.serverId) throw new Error('Playlist owner unavailable'); - await deletePlaylistForServer(playlist.serverId, playlist.id); - removeId(playlist.id, playlist.serverId); + await deletePlaylist(playlist.id); + removeId(playlist.id); // Update local playlist state without page reload to preserve audio playback state usePlaylistStore.setState((s) => ({ - playlists: s.playlists.filter((p) => p.id !== playlist.id || p.serverId !== playlist.serverId), + playlists: s.playlists.filter((p) => p.id !== playlist.id), })); showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info'); } catch { @@ -126,25 +125,24 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { {offlinePolicy.canEditPlaylist && (
handleAction(async () => { const { showToast } = await import('@/lib/dom/toast'); - const { deletePlaylistForServer } = await import('@/lib/api/subsonicPlaylists'); + const { deletePlaylist } = await import('@/lib/api/subsonicPlaylists'); const { removeId } = usePlaylistStore.getState(); - const deletedIds = new Set(); + const deletedIds: string[] = []; for (const pl of selectedPlaylists) { try { - if (!pl.serverId) throw new Error('Playlist owner unavailable'); - await deletePlaylistForServer(pl.serverId, pl.id); - removeId(pl.id, pl.serverId); - deletedIds.add(`${pl.serverId}:${pl.id}`); + await deletePlaylist(pl.id); + removeId(pl.id); + deletedIds.push(pl.id); } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } } - if (deletedIds.size > 0) { + if (deletedIds.length > 0) { // Update local playlist state without page reload to preserve audio playback state usePlaylistStore.setState((s) => ({ - playlists: s.playlists.filter((p) => !deletedIds.has(`${p.serverId ?? ''}:${p.id}`)), + playlists: s.playlists.filter((p) => !deletedIds.includes(p.id)), })); - showToast(t('playlists.deleteSuccess', { count: deletedIds.size }), 3000, 'info'); + showToast(t('playlists.deleteSuccess', { count: deletedIds.length }), 3000, 'info'); } })}> {t('playlists.deleteSelected')} diff --git a/src/features/contextMenu/components/QueueItemContextItems.tsx b/src/features/contextMenu/components/QueueItemContextItems.tsx index 4ef3b1b7..d5cf416b 100644 --- a/src/features/contextMenu/components/QueueItemContextItems.tsx +++ b/src/features/contextMenu/components/QueueItemContextItems.tsx @@ -110,7 +110,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { />
-
handleAction(() => copyShareLink('track', song.id, song.serverId))}> +
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
handleAction(() => openSongInfo(song.id))}> diff --git a/src/features/contextMenu/components/SongContextItems.tsx b/src/features/contextMenu/components/SongContextItems.tsx index 46cd327e..beb84cee 100644 --- a/src/features/contextMenu/components/SongContextItems.tsx +++ b/src/features/contextMenu/components/SongContextItems.tsx @@ -171,7 +171,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
-
handleAction(() => copyShareLink('track', song.id, song.serverId))}> +
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
handleAction(() => openSongInfo(song.id))}> @@ -179,18 +179,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
handleAction(async () => { - const { removePlaylistSongsAtIndicesForServer } = await import('@/lib/api/subsonicPlaylists'); + const { removePlaylistSongsAtIndices } = await import('@/lib/api/subsonicPlaylists'); const { showToast } = await import('@/lib/dom/toast'); const { touchPlaylist } = usePlaylistStore.getState(); const membership = usePlaylistMembershipStore.getState(); try { - if (!song.serverId) throw new Error('Playlist owner unavailable'); - await removePlaylistSongsAtIndicesForServer(song.serverId, playlistId, [playlistSongIndex]); - membership.removePlaylistSongIdsAtIndices(playlistId, [playlistSongIndex], song.serverId); - touchPlaylist(playlistId, song.serverId); + await removePlaylistSongsAtIndices(playlistId, [playlistSongIndex]); + membership.removePlaylistSongIdsAtIndices(playlistId, [playlistSongIndex]); + touchPlaylist(playlistId); showToast(t('playlists.removeSuccess'), 3000, 'info'); } catch { - membership.invalidatePlaylistSongIds(playlistId, song.serverId); + membership.invalidatePlaylistSongIds(playlistId); showToast(t('playlists.removeError'), 4000, 'error'); } })}> @@ -312,7 +311,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
-
handleAction(() => copyShareLink('track', song.id, song.serverId))}> +
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
handleAction(() => openSongInfo(song.id))}> diff --git a/src/features/contextMenu/components/contextMenuItemTypes.ts b/src/features/contextMenu/components/contextMenuItemTypes.ts index a314d3a4..2ffcb309 100644 --- a/src/features/contextMenu/components/contextMenuItemTypes.ts +++ b/src/features/contextMenu/components/contextMenuItemTypes.ts @@ -52,7 +52,7 @@ export interface ContextMenuItemsProps { startRadio: (artistId: string, artistName: string, seedTrack?: Track) => void; startInstantMix: (song: Track) => void; downloadAlbum: (albumName: string, albumId: string) => Promise; - copyShareLink: (kind: EntityShareKind, id: string, representativeServerId?: string) => void; + copyShareLink: (kind: EntityShareKind, id: string) => void; isStarred: (id: string, itemStarred?: string) => boolean; /** When true, album/artist links switch to the queue server before routing. */ pinToPlaybackServer: boolean; diff --git a/src/features/contextMenu/hooks/useContextMenuRating.ts b/src/features/contextMenu/hooks/useContextMenuRating.ts index 1bc920ec..1e54f375 100644 --- a/src/features/contextMenu/hooks/useContextMenuRating.ts +++ b/src/features/contextMenu/hooks/useContextMenuRating.ts @@ -1,9 +1,10 @@ import { useCallback } from 'react'; -import { queueEntityRating, queueSongRating } from '@/features/playback'; +import { setRating } from '@/lib/api/subsonicStarRating'; +import { queueSongRating } from '@/features/playback/store/pendingStarSync'; import type { SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes'; import type { Track } from '@/lib/media/trackTypes'; import { useAuthStore } from '@/store/authStore'; -import { entityOverrideKey } from '@/lib/media/entityOverrideKey'; +import { showToast } from '@/lib/dom/toast'; type RatingKind = 'song' | 'album' | 'artist'; @@ -25,59 +26,75 @@ interface Result { } export function useContextMenuRating({ - type, item, userRatingOverrides, entityRatingSupport, + type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t, }: Args): Result { + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const activeServerId = useAuthStore(s => s.activeServerId); const applySongRating = useCallback((songId: string, rating: number) => { // F4: optimistic override + retry-with-backoff sync via the central helper. - const song = item as Track; - queueSongRating(songId, rating, song.serverId ?? activeServerId ?? undefined); - }, [item, activeServerId]); + queueSongRating(songId, rating); + }, []); const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => { + setUserRatingOverride(album.id, rating); if (entityRatingSupport !== 'full') return; - queueEntityRating('album', album.id, rating, album.serverId ?? activeServerId ?? undefined); - }, [entityRatingSupport, activeServerId]); + setRating(album.id, rating).catch(err => { + if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + }); + }, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]); const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => { + setUserRatingOverride(artist.id, rating); if (entityRatingSupport !== 'full') return; - queueEntityRating('artist', artist.id, rating, artist.serverId ?? activeServerId ?? undefined); - }, [entityRatingSupport, activeServerId]); + setRating(artist.id, rating).catch(err => { + if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + }); + }, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]); const getRatingValueByKind = useCallback((kind: RatingKind, id: string): number => { if (kind === 'song' && (type === 'song' || type === 'album-song' || type === 'queue-item')) { const song = item as Track; - if (song.id === id) return userRatingOverrides[entityOverrideKey(song.serverId ?? activeServerId, id)] ?? song.userRating ?? 0; + if (song.id === id) return userRatingOverrides[id] ?? song.userRating ?? 0; } if (kind === 'album' && type === 'album') { const album = item as SubsonicAlbum; - if (album.id === id) return userRatingOverrides[entityOverrideKey(album.serverId ?? activeServerId, id)] ?? album.userRating ?? 0; + if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0; } if (kind === 'album' && type === 'multi-album') { const albums = item as SubsonicAlbum[]; const compositeId = [...albums.map(a => a.id)].sort().join('\x1e'); if (id !== compositeId) return userRatingOverrides[id] ?? 0; if (albums.length === 0) return 0; - const vals = albums.map(a => userRatingOverrides[entityOverrideKey(a.serverId ?? activeServerId, a.id)] ?? a.userRating ?? 0); + const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); const first = vals[0]; return vals.every(v => v === first) ? first : 0; } if (kind === 'artist' && type === 'artist') { const artist = item as SubsonicArtist; - if (artist.id === id) return userRatingOverrides[entityOverrideKey(artist.serverId ?? activeServerId, id)] ?? artist.userRating ?? 0; + if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0; } if (kind === 'artist' && type === 'multi-artist') { const artists = item as SubsonicArtist[]; const compositeId = [...artists.map(a => a.id)].sort().join('\x1e'); if (id !== compositeId) return userRatingOverrides[id] ?? 0; if (artists.length === 0) return 0; - const vals = artists.map(a => userRatingOverrides[entityOverrideKey(a.serverId ?? activeServerId, a.id)] ?? a.userRating ?? 0); + const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); const first = vals[0]; return vals.every(v => v === first) ? first : 0; } return userRatingOverrides[id] ?? 0; - }, [type, item, userRatingOverrides, activeServerId]); + }, [type, item, userRatingOverrides]); const commitRatingByKind = useCallback((kind: RatingKind, id: string, rating: number) => { if (kind === 'song') { diff --git a/src/features/contextMenu/utils/contextMenuActions.ts b/src/features/contextMenu/utils/contextMenuActions.ts index 3ab60b4b..4857135e 100644 --- a/src/features/contextMenu/utils/contextMenuActions.ts +++ b/src/features/contextMenu/utils/contextMenuActions.ts @@ -19,9 +19,8 @@ export async function copyShareLink( kind: EntityShareKind, id: string, t: (key: string) => string, - representativeServerId?: string, ) { - const ok = await copyEntityShareLink(kind, id, representativeServerId); + const ok = await copyEntityShareLink(kind, id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } diff --git a/src/features/folderBrowser/hooks/useFolderBrowserNowPlayingPath.ts b/src/features/folderBrowser/hooks/useFolderBrowserNowPlayingPath.ts index 8947ae7e..cffbfbe2 100644 --- a/src/features/folderBrowser/hooks/useFolderBrowserNowPlayingPath.ts +++ b/src/features/folderBrowser/hooks/useFolderBrowserNowPlayingPath.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { getMusicDirectoryForServer, getMusicIndexesForServer } from '@/lib/api/subsonicLibrary'; +import { getMusicDirectory, getMusicIndexes } from '@/lib/api/subsonicLibrary'; import type { SubsonicDirectoryEntry } from '@/lib/api/subsonicTypes'; import type { Track } from '@/lib/media/trackTypes'; import type { Column, NavPos } from '@/features/folderBrowser/utils/folderBrowserHelpers'; -let persistedPlayingPath: { serverId: string; ids: string[] } | null = null; +let persistedPlayingPathIds: string[] = []; interface Args { columns: Column[]; @@ -24,10 +24,7 @@ interface Result { export function useFolderBrowserNowPlayingPath({ columns, currentTrack, isPlaying, setColumns, setKeyboardPos, }: Args): Result { - const [playingPathIds, setPlayingPathIds] = useState(() => { - const persisted = persistedPlayingPath; - return persisted && persisted.serverId === currentTrack?.serverId ? persisted.ids : []; - }); + const [playingPathIds, setPlayingPathIds] = useState(persistedPlayingPathIds); const autoResolvedTrackRef = useRef(null); const prevTrackIdRef = useRef(null); const lastHotkeyRevealTsRef = useRef(null); @@ -41,7 +38,7 @@ export function useFolderBrowserNowPlayingPath({ return; } setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : [])); - }, [currentTrack?.id, currentTrack?.serverId]); + }, [currentTrack?.id]); useEffect(() => { if (!isPlaying || !currentTrack?.id) return; @@ -69,21 +66,17 @@ export function useFolderBrowserNowPlayingPath({ }, [columns, currentTrack?.id, isPlaying]); useEffect(() => { - persistedPlayingPath = currentTrack?.serverId - ? { serverId: currentTrack.serverId, ids: playingPathIds } - : null; - }, [playingPathIds, currentTrack?.serverId]); + persistedPlayingPathIds = playingPathIds; + }, [playingPathIds]); const resolveColumnsForTrack = useCallback(async ( track: Track, roots: SubsonicDirectoryEntry[], ): Promise => { for (const root of roots) { - const serverId = root.serverId ?? track.serverId; - if (!serverId || (track.serverId && track.serverId !== serverId)) continue; let indexes: SubsonicDirectoryEntry[]; try { - indexes = await getMusicIndexesForServer(serverId, root.id); + indexes = await getMusicIndexes(root.id); } catch { continue; } @@ -95,7 +88,7 @@ export function useFolderBrowserNowPlayingPath({ let artistChildren: SubsonicDirectoryEntry[]; try { - artistChildren = (await getMusicDirectoryForServer(serverId, artistEntry.id)).child; + artistChildren = (await getMusicDirectory(artistEntry.id)).child; } catch { continue; } @@ -111,7 +104,7 @@ export function useFolderBrowserNowPlayingPath({ let albumChildren: SubsonicDirectoryEntry[]; try { - albumChildren = (await getMusicDirectoryForServer(serverId, albumEntry.id)).child; + albumChildren = (await getMusicDirectory(albumEntry.id)).child; } catch { continue; } @@ -119,10 +112,10 @@ export function useFolderBrowserNowPlayingPath({ if (!songEntry) continue; return [ - { id: 'root', serverId: '', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' }, - { id: root.id, serverId, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' }, - { id: artistEntry.id, serverId, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' }, - { id: albumEntry.id, serverId, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' }, + { id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' }, + { id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' }, + { id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' }, + { id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' }, ]; } return null; diff --git a/src/features/folderBrowser/pages/FolderBrowser.tsx b/src/features/folderBrowser/pages/FolderBrowser.tsx index d6a11c4f..d7f2e8f7 100644 --- a/src/features/folderBrowser/pages/FolderBrowser.tsx +++ b/src/features/folderBrowser/pages/FolderBrowser.tsx @@ -1,8 +1,4 @@ -import { - getMusicDirectoryForServer, - getMusicFoldersForServer, - getMusicIndexesForServer, -} from '@/lib/api/subsonicLibrary'; +import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '@/lib/api/subsonicLibrary'; import type { SubsonicDirectoryEntry, SubsonicArtist } from '@/lib/api/subsonicTypes'; import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { usePlayerStore } from '@/features/playback/store/playerStore'; @@ -15,7 +11,6 @@ import FolderBrowserColumn from '@/features/folderBrowser/components/FolderBrows import { useFolderBrowserNowPlayingPath } from '@/features/folderBrowser/hooks/useFolderBrowserNowPlayingPath'; import { useFolderBrowserScrolling } from '@/features/folderBrowser/hooks/useFolderBrowserScrolling'; import { useFolderBrowserKeyboardNav } from '@/features/folderBrowser/hooks/useFolderBrowserKeyboardNav'; -import { useReachableLibrarySources } from '@/store/useReachableLibrarySources'; export default function FolderBrowser() { const { t } = useTranslation(); @@ -32,7 +27,6 @@ export default function FolderBrowser() { const playTrack = usePlayerStore(s => s.playTrack); const openContextMenu = usePlayerStore(s => s.openContextMenu); const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - const sources = useReachableLibrarySources(); const { wrapperRef, columnsViewportWidth } = useFolderBrowserScrolling({ columns, keyboardPos, keyboardNavActive, setKeyboardNavActive, @@ -44,7 +38,6 @@ export default function FolderBrowser() { useEffect(() => { const placeholder: Column = { id: 'root', - serverId: '', name: '', items: [], selectedId: null, @@ -55,23 +48,19 @@ export default function FolderBrowser() { // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect setColumns([placeholder]); - Promise.all(sources.map(async source => { - const folders = await getMusicFoldersForServer(source.serverId); - return folders.map(f => ({ - id: f.id, - title: sources.length > 1 ? `${source.name} · ${f.name}` : f.name, - isDir: true, - serverId: source.serverId, - } satisfies SubsonicDirectoryEntry)); - })) - .then(groups => { - const items = groups.flat(); + getMusicFolders() + .then(folders => { + const items: SubsonicDirectoryEntry[] = folders.map(f => ({ + id: f.id, + title: f.name, + isDir: true, + })); setColumns([{ ...placeholder, items, loading: false }]); }) .catch(() => { setColumns([{ ...placeholder, items: [], loading: false, error: true }]); }); - }, [sources]); + }, []); useEffect(() => { // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. @@ -182,7 +171,6 @@ export default function FolderBrowser() { ), { id: item.id, - serverId: item.serverId ?? '', name: item.title, items: [], selectedId: null, @@ -192,11 +180,8 @@ export default function FolderBrowser() { }, ]); - const ownerServerId = item.serverId ?? columns[colIndex]?.serverId; - if (!ownerServerId) return; - const fetchItems = colIndex === 0 - ? getMusicIndexesForServer(ownerServerId, item.id) - : getMusicDirectoryForServer(ownerServerId, item.id).then(d => d.child); + const fetchItems = + colIndex === 0 ? getMusicIndexes(item.id) : getMusicDirectory(item.id).then(d => d.child); fetchItems .then(items => { @@ -217,7 +202,7 @@ export default function FolderBrowser() { return next; }); }); - }, [clearFiltersRightOf, columns]); + }, [clearFiltersRightOf]); const handleFileClick = useCallback( (colIndex: number, item: SubsonicDirectoryEntry) => { @@ -347,7 +332,7 @@ export default function FolderBrowser() { > {columns.map((col, colIndex) => ( { - it('carries the directory source into playback and actions', () => { - expect(entryToTrack({ - id: 'same', title: 'Track', isDir: false, serverId: 'server-b', - }).serverId).toBe('server-b'); - }); -}); diff --git a/src/features/folderBrowser/utils/folderBrowserHelpers.ts b/src/features/folderBrowser/utils/folderBrowserHelpers.ts index 96e46e78..6d27e687 100644 --- a/src/features/folderBrowser/utils/folderBrowserHelpers.ts +++ b/src/features/folderBrowser/utils/folderBrowserHelpers.ts @@ -7,7 +7,6 @@ export type NavPos = { colIndex: number; rowIndex: number }; export type Column = { id: string; - serverId: string; name: string; items: SubsonicDirectoryEntry[]; selectedId: string | null; @@ -33,7 +32,6 @@ export function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlb userRating: item.userRating, songCount: 0, duration: 0, - serverId: item.serverId, }; } @@ -54,7 +52,6 @@ export function entryToTrack(e: SubsonicDirectoryEntry): Track { genre: e.genre, starred: e.starred, userRating: e.userRating, - serverId: e.serverId, }; } diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx index 00a55283..65eb69fa 100644 --- a/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx +++ b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx @@ -1,5 +1,4 @@ import { queueSongStar, playbackCoverArtForAlbum, usePlayerStore } from '@/features/playback'; -import { entityOverrideKey } from '@/lib/media/entityOverrideKey'; import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt'; import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef'; import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; @@ -40,8 +39,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const isStarred = usePlayerStore(s => { const track = s.currentTrack; if (!track) return false; - const key = entityOverrideKey(track.serverId ?? s.queueServerId, track.id); - return key in s.starredOverrides ? s.starredOverrides[key] : !!track.starred; + return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred; }); const toggleStar = useCallback(() => { diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx index 610b8d05..65b04669 100644 --- a/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx +++ b/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx @@ -5,8 +5,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '@/features/playback/store/playerStore'; -import { queueSongStar, queueSongRating } from '@/features/playback'; -import { entityOverrideKey } from '@/lib/media/entityOverrideKey'; +import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync'; import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef'; import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt'; import { useCachedUrl } from '@/ui/CachedImage'; @@ -69,8 +68,7 @@ export default function FullscreenPlayerStatic({ onClose }: Props) { const isStarred = usePlayerStore(s => { const track = s.currentTrack; if (!track) return false; - const key = entityOverrideKey(track.serverId ?? s.queueServerId, track.id); - return key in s.starredOverrides ? s.starredOverrides[key] : !!track.starred; + return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred; }); const toggleStar = useCallback(() => { if (!currentTrack) return; @@ -119,15 +117,14 @@ export default function FullscreenPlayerStatic({ onClose }: Props) { const rating = usePlayerStore(s => { const track = s.currentTrack; if (!track) return 0; - const key = entityOverrideKey(track.serverId ?? s.queueServerId, track.id); - return key in s.userRatingOverrides ? s.userRatingOverrides[key] : (track.userRating ?? 0); + return track.id in s.userRatingOverrides ? s.userRatingOverrides[track.id] : (track.userRating ?? 0); }); // Hover preview for the clickable rating stars (0 = no preview). const [hoverRating, setHoverRating] = useState(0); const applyRating = useCallback((stars: number) => { if (!currentTrack) return; // Click the current rating again to clear it (matches StarRating's toggle-off). - queueSongRating(currentTrack.id, rating === stars ? 0 : stars, currentTrack.serverId); + queueSongRating(currentTrack.id, rating === stars ? 0 : stars); }, [currentTrack, rating]); return ( diff --git a/src/features/genre/pages/Genres.tsx b/src/features/genre/pages/Genres.tsx index ddfd3e9a..e87290d2 100644 --- a/src/features/genre/pages/Genres.tsx +++ b/src/features/genre/pages/Genres.tsx @@ -7,13 +7,13 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll'; import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { fetchGenreCatalog, filterGenresWithContent } from '@/features/playback/utils/playback/genreBrowsePlayback'; +import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient'; import { peekGenreCatalogCache } from '@/lib/library/genreCatalogCountsCache'; import { genreColor } from '@/lib/library/genreColor'; import { useOfflineBrowseContext, offlineLocalBrowseEnabled } from '@/features/offline'; import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision'; import { useOfflineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; -import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope'; const SCROLL_KEY = 'genres-scroll'; const FONT_MIN_REM = 0.78; @@ -23,11 +23,9 @@ export default function Genres() { const { t } = useTranslation(); const navigate = useNavigate(); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const browseScope = useBrowseLibraryScope(); - const browseServerId = browseScope.anchorServerId || serverId; const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const libraryScopeKey = browseScope.fingerprint; + const libraryScopeKey = libraryScopeCacheKeyForServer(serverId); const offlineBrowseActive = useOfflineBrowseContext().active; const localPlaybackEntries = useLocalPlaybackStore(s => s.entries); const librarySyncRevision = useOfflineLocalLibrarySyncRevision(serverId || null); @@ -45,7 +43,7 @@ export default function Genres() { useEffect(() => { let cancelled = false; - const scopeKey = browseScope.fingerprint; + const scopeKey = libraryScopeCacheKeyForServer(serverId); const cached = serverId && !skipGenreCatalogCache ? peekGenreCatalogCache(serverId, scopeKey, true) : null; @@ -57,13 +55,7 @@ export default function Genres() { } else { setLoading(true); } - void fetchGenreCatalog( - browseServerId, - indexEnabled, - browseScope.pairs, - browseScope.multiServer, - browseScope.fingerprint, - ) + void fetchGenreCatalog(serverId, indexEnabled) .then(data => { if (!cancelled) setRawGenres(data); }) @@ -73,7 +65,7 @@ export default function Genres() { return () => { cancelled = true; }; - }, [browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId, serverId, indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, skipGenreCatalogCache, librarySyncRevision, offlineLocalBrowseReloadKey]); + }, [serverId, indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, skipGenreCatalogCache, librarySyncRevision, offlineLocalBrowseReloadKey]); const genres = useMemo( () => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount), diff --git a/src/features/home/components/BecauseYouLikeRail.tsx b/src/features/home/components/BecauseYouLikeRail.tsx index 7fed71ff..b8af25f2 100644 --- a/src/features/home/components/BecauseYouLikeRail.tsx +++ b/src/features/home/components/BecauseYouLikeRail.tsx @@ -1,5 +1,5 @@ -import { getArtistForServer, getArtistInfoForServer } from '@/lib/api/subsonicArtists'; -import { filterAlbumsToServerLibrary } from '@/lib/api/subsonicLibrary'; +import { getArtist, getArtistInfo } from '@/lib/api/subsonicArtists'; +import { filterAlbumsToActiveLibrary } from '@/lib/api/subsonicLibrary'; import { resolveAlbum, resolveMediaServerId } from '@/features/offline'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { songToTrack } from '@/lib/media/songToTrack'; @@ -26,9 +26,6 @@ import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay'; import { formatHumanHoursMinutes } from '@/lib/format/formatHumanDuration'; import { AlbumRow } from '@/features/album'; import { albumArtistDisplayName } from '@/features/album'; -import { appendServerQuery } from '@/lib/navigation/detailServerScope'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; -import { coverServerScopeForServerId } from '@/cover/serverScope'; const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:'; const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:'; @@ -60,8 +57,7 @@ const ROW_STAGGER_MS = 150; // ── Module-level reserve: next batch pre-fetched in background after each display ── type BecauseReserve = { - sourceKey: string; - sourceServerId: string; + serverId: string; filterVersion: number; // poolKey intentionally omitted — reserve is valid for any pool state on the // same server. Pool (top-played artists) changes slowly; showing a slightly-off @@ -93,20 +89,18 @@ function readJsonArray(key: string | null): string[] { async function resolvePicks( candidate: BecauseYouLikeAnchor, recentPicks: Set, - sourceServerId: string, ): Promise { - const info = await getArtistInfoForServer(sourceServerId, candidate.id, { similarArtistCount: SIMILAR_FETCH }); + const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH }); const similar = (info.similarArtist ?? []).filter(s => s.id); if (similar.length === 0) return null; const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK); - const results = await Promise.all(sampled.map(s => getArtistForServer(sourceServerId, s.id).catch(() => null))); + const results = await Promise.all(sampled.map(s => getArtist(s.id).catch(() => null))); const picks: SubsonicAlbum[] = []; for (const r of results) { if (!r) continue; - const albums = (await filterAlbumsToServerLibrary(r.albums, sourceServerId)) - .map(album => ({ ...album, serverId: sourceServerId })); + const albums = await filterAlbumsToActiveLibrary(r.albums); if (albums.length === 0) continue; const fresh = albums.filter(a => !recentPicks.has(a.id)); const choice = fresh.length > 0 ? fresh : albums; @@ -133,7 +127,6 @@ async function fetchBecauseYouLike( pool: BecauseYouLikeAnchor[], anchorHistKey: string | null, picksHistKey: string | null, - sourceServerId: string, ): Promise { const anchorHistory = readJsonArray(anchorHistKey); const picksHistory = readJsonArray(picksHistKey); @@ -160,7 +153,7 @@ async function fetchBecauseYouLike( const raced = await Promise.all( tryList.slice(0, 2).map(async candidate => { try { - const picks = await resolvePicks(candidate, recentPicks, sourceServerId); + const picks = await resolvePicks(candidate, recentPicks); return picks ? { candidate, picks } : null; } catch { return null; @@ -173,7 +166,7 @@ async function fetchBecauseYouLike( for (const candidate of tryList) { try { - const picks = await resolvePicks(candidate, recentPicks, sourceServerId); + const picks = await resolvePicks(candidate, recentPicks); if (!picks) continue; return buildResult(candidate, picks); } catch { @@ -193,8 +186,7 @@ async function fetchBecauseYouLike( */ async function fillBecauseReserve( pool: BecauseYouLikeAnchor[], - sourceKey: string, - sourceServerId: string, + serverId: string, filterVersion: number, anchorHistKey: string | null, picksHistKey: string | null, @@ -202,12 +194,12 @@ async function fillBecauseReserve( if (_becauseReserveFilling) return; _becauseReserveFilling = true; try { - const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey, sourceServerId); + const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey); if (result) { - _becauseReserve = { sourceKey, sourceServerId, filterVersion, ...result }; + _becauseReserve = { serverId, filterVersion, ...result }; // Also refresh the session snapshot so a quick leave→return can pick up // newer cards even before the reserve is explicitly consumed. - writeBecauseYouLikeCache({ sourceKey, filterVersion, anchor: result.anchor, recs: result.recs }); + writeBecauseYouLikeCache({ serverId, filterVersion, anchor: result.anchor, recs: result.recs }); } } catch { /* Network failure — next visit falls back to a fresh fetch. */ @@ -291,8 +283,6 @@ function BecauseYouLikeSkeleton({ title, slotCount }: { title: string; slotCount } interface Props { - sourceKey: string; - sourceServerId?: string | null; mostPlayed: SubsonicAlbum[]; recentlyPlayed?: SubsonicAlbum[]; starred?: SubsonicAlbum[]; @@ -330,24 +320,22 @@ function picksHistoryKey(serverId: string | null): string | null { return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null; } -function hasValidReserve(sourceKey: string | null, sourceServerId: string | null, filterVersion: number): boolean { +function hasValidReserve(serverId: string | null, filterVersion: number): boolean { return ( _becauseReserve != null && - _becauseReserve.sourceKey === (sourceKey ?? '') && - _becauseReserve.sourceServerId === (sourceServerId ?? '') && + _becauseReserve.serverId === (serverId ?? '') && _becauseReserve.filterVersion === filterVersion ); } export default function BecauseYouLikeRail({ - sourceKey, - sourceServerId, mostPlayed, recentlyPlayed, starred, disableArtwork = false, }: Props) { const { t } = useTranslation(); + const activeServerId = useAuthStore(s => s.activeServerId); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const pool = useMemo( () => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL), @@ -361,18 +349,18 @@ export default function BecauseYouLikeRail({ // revalidate) > skeleton. Both checks work without poolKey so they fire correctly on the // first render when pool is still [] (Home.tsx loads mostPlayed asynchronously). const [anchor, setAnchor] = useState(() => { - if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) return _becauseReserve!.anchor; - return readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion)?.anchor ?? null; + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.anchor; + return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.anchor ?? null; }); const [recs, setRecs] = useState(() => { - if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) return _becauseReserve!.recs; - return readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion)?.recs ?? []; + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.recs; + return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.recs ?? []; }); const containerRef = useRef(null); const [narrow, setNarrow] = useState(false); const [refreshing, setRefreshing] = useState(() => { - if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) return false; - const snap = readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion); + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return false; + const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); return !snap || snap.recs.length === 0; }); const skeletonSlots = useBecauseRowSlotCount(refreshing, SHOW_COUNT); @@ -383,14 +371,14 @@ export default function BecauseYouLikeRail({ * (synchronous, before browser paint) or fall back to session cache (stale- * while-revalidate), only clearing to skeleton when nothing is available. */ useLayoutEffect(() => { - if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) { + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) { // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect setAnchor(_becauseReserve!.anchor); setRecs(_becauseReserve!.recs); setRefreshing(false); } else { - const snap = readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion); + const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); if (snap && snap.recs.length > 0) { setAnchor(snap.anchor); setRecs(snap.recs); @@ -401,7 +389,7 @@ export default function BecauseYouLikeRail({ setRecs([]); } } - }, [sourceKey, sourceServerId, musicLibraryFilterVersion, poolKey]); + }, [activeServerId, musicLibraryFilterVersion, poolKey]); // 696px ≙ exactly 2 BecauseCards side-by-side (2*340 + 16 gap). Below that // the hero-style cards stretch full-width and dwarf the rest of the page, @@ -428,7 +416,7 @@ export default function BecauseYouLikeRail({ // cache content. The effect will re-run once pool is populated. return; } - if (!sourceServerId) { + if (!activeServerId) { // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect setAnchor(null); @@ -437,12 +425,12 @@ export default function BecauseYouLikeRail({ return; } - const anchorHistKey = anchorHistoryKey(sourceServerId); - const picksHistKey = picksHistoryKey(sourceServerId); - const snap = readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion); + const anchorHistKey = anchorHistoryKey(activeServerId); + const picksHistKey = picksHistoryKey(activeServerId); + const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); // Consume module-level reserve (keyed by server + library scope). - const reserved = hasValidReserve(sourceKey, sourceServerId, musicLibraryFilterVersion) ? _becauseReserve : null; + const reserved = hasValidReserve(activeServerId, musicLibraryFilterVersion) ? _becauseReserve : null; _becauseReserve = null; (async () => { @@ -460,9 +448,9 @@ export default function BecauseYouLikeRail({ } catch { /* ignore */ } setAnchor(reserved.anchor); setRecs(reserved.recs); - if (sourceServerId) { + if (activeServerId) { writeBecauseYouLikeCache({ - sourceKey, + serverId: activeServerId, filterVersion: musicLibraryFilterVersion, anchor: reserved.anchor, recs: reserved.recs, @@ -470,7 +458,7 @@ export default function BecauseYouLikeRail({ } setRefreshing(false); // Pre-fetch the next batch so the next visit is also instant. - void fillBecauseReserve(pool, sourceKey, sourceServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); + void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); return; } @@ -479,7 +467,7 @@ export default function BecauseYouLikeRail({ // for the next mount instead of swapping cards mid-visit. if (snap && snap.recs.length > 0) { setRefreshing(false); - void fillBecauseReserve(pool, sourceKey, sourceServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); + void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); return; } @@ -493,7 +481,7 @@ export default function BecauseYouLikeRail({ setRecs([]); } - const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey, sourceServerId); + const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey); if (cancelled) return; if (result) { @@ -508,9 +496,9 @@ export default function BecauseYouLikeRail({ } catch { /* ignore */ } setAnchor(result.anchor); setRecs(result.recs); - if (sourceServerId) { + if (activeServerId) { writeBecauseYouLikeCache({ - sourceKey, + serverId: activeServerId, filterVersion: musicLibraryFilterVersion, anchor: result.anchor, recs: result.recs, @@ -518,7 +506,7 @@ export default function BecauseYouLikeRail({ } setRefreshing(false); // Pre-fetch next batch so the next visit is instant. - void fillBecauseReserve(pool, sourceKey, sourceServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); + void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); } else { // Network failed — restore session cache if available. if (snap) { @@ -544,7 +532,7 @@ export default function BecauseYouLikeRail({ // swap the cards — a height blip above the row that scroll anchoring turns into // an upward viewport jump. The sibling reserve effect already keys on poolKey. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [poolKey, sourceKey, sourceServerId, musicLibraryFilterVersion, disableArtwork]); + }, [poolKey, activeServerId, musicLibraryFilterVersion, disableArtwork]); useLibraryCoverPrefetch( disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }], @@ -582,7 +570,7 @@ export default function BecauseYouLikeRail({
{recs.slice(0, contentSlots).map((album, index) => ( playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined), - onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined), + onShortPress: () => playAlbum(album.id), + onLongPress: () => playAlbumShuffled(album.id), }); const navigate = useNavigate(); const enqueue = usePlayerStore(s => s.enqueue); - const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScopeForServerId(album.serverId), { libraryResolve: false }); + const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false }); const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, { surface: 'dense', ensurePriority: 'high', @@ -619,7 +607,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e const imgSrc = coverImgSrc(coverHandle.src); const bgResolved = coverHandle.src; const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]); - const handleOpen = () => navigate({ pathname: `/album/${album.id}`, search: appendServerQuery(undefined, album.serverId) }); + const handleOpen = () => navigate(`/album/${album.id}`); const handleEnqueue = async (e: React.MouseEvent) => { e.stopPropagation(); try { diff --git a/src/features/home/components/Hero.tsx b/src/features/home/components/Hero.tsx index ede9b694..00065534 100644 --- a/src/features/home/components/Hero.tsx +++ b/src/features/home/components/Hero.tsx @@ -23,9 +23,6 @@ import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback import { useLongPressAction } from '@/lib/hooks/useLongPressAction'; import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay'; import { albumArtistDisplayName, deriveAlbumArtistRefs } from '@/features/album'; -import { appendServerQuery } from '@/lib/navigation/detailServerScope'; -import { libraryEntityKey } from '@/lib/library/libraryEntityKey'; -import { coverServerScopeForServerId } from '@/cover/serverScope'; const INTERVAL_MS = 10000; const HERO_ALBUM_COUNT = 8; @@ -122,10 +119,9 @@ function HeroBg({ url, position }: { url: string; position?: string }) { interface HeroProps { albums?: SubsonicAlbum[]; - fallbackToNetwork?: boolean; } -export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: HeroProps = {}) { +export default function Hero({ albums: albumsProp }: HeroProps = {}) { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const navigateToAlbum = useNavigateToAlbum(); @@ -248,7 +244,6 @@ export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: H // React Compiler set-state-in-effect rule: state set from a timer/animation callback. // eslint-disable-next-line react-hooks/set-state-in-effect if (albumsProp?.length) { setAlbums(albumsProp); return; } - if (!fallbackToNetwork) { setAlbums([]); return; } const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 }; const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0); const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT; @@ -262,7 +257,6 @@ export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: H .catch(() => {}); }, [ albumsProp, - fallbackToNetwork, musicLibraryFilterVersion, mixMinRatingFilterEnabled, mixMinRatingAlbum, @@ -325,32 +319,27 @@ export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: H // Lazily fetch format label for the currently-visible album (cached by id) const [albumFormats, setAlbumFormats] = useState>({}); - const albumKey = album ? libraryEntityKey(album) : ''; useEffect(() => { - if (!album || albumFormats[albumKey] !== undefined) return; + if (!album || albumFormats[album.id] !== undefined) return; const serverId = resolveMediaServerId(album.serverId); if (!serverId) return; resolveAlbum(serverId, album.id).then(data => { if (!data) { - setAlbumFormats(prev => ({ ...prev, [albumKey]: '' })); + setAlbumFormats(prev => ({ ...prev, [album.id]: '' })); return; } const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))]; - setAlbumFormats(prev => ({ ...prev, [albumKey]: fmts.map(f => f.toUpperCase()).join(' / ') })); + setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') })); }).catch(() => { - setAlbumFormats(prev => ({ ...prev, [albumKey]: '' })); + setAlbumFormats(prev => ({ ...prev, [album.id]: '' })); }); // Intentionally keyed on album?.id only: the format label is fetched once per // album id and cached in albumFormats. Depending on the album object or the // albumFormats map would re-run on every render / cache write. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [albumKey]); + }, [album?.id]); - const heroCoverRef = useAlbumCoverRef( - album?.id, - album?.coverArt, - coverServerScopeForServerId(album?.serverId), - ); + const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt); const albumId = album?.id; // Mainstage hero backdrop — the album artist's fanart (banner → 16:9 fanart), @@ -388,8 +377,8 @@ export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: H !perfFlags.disableMainstageHeroBackdrop && heroInView; const { isHolding, pressBind } = useLongPressAction({ - onShortPress: () => { if (albumId) playAlbum(albumId, album.serverId ? { serverId: album.serverId } : undefined); }, - onLongPress: () => { if (albumId) playAlbumShuffled(albumId, album.serverId ? { serverId: album.serverId } : undefined); }, + onShortPress: () => { if (albumId) playAlbum(albumId); }, + onLongPress: () => { if (albumId) playAlbumShuffled(albumId); }, }); if (!album) return
; @@ -400,14 +389,14 @@ export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: H className="hero" role="banner" aria-label={t('hero.eyebrow')} - onClick={() => navigateToAlbum(album.id, { search: appendServerQuery(undefined, album.serverId) })} + onClick={() => navigateToAlbum(album.id)} style={{ cursor: 'pointer' }} > {showHeroBackdrop && } {showHeroBackdrop &&