From 6856ede1970dccaebda9e4152259e718ec0d0ff2 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:30:16 +0300 Subject: [PATCH] fix(library): stabilize primary browse server --- src/features/home/pages/homeFeedLoader.ts | 6 +-- src/lib/library/libraryBrowseScope.test.ts | 35 ++++++++++++++++- src/lib/library/libraryBrowseScope.ts | 45 +++++++++++++++++----- src/store/authMusicLibraryActions.ts | 12 +++--- src/store/authServerProfileActions.ts | 35 +++++------------ src/store/authStore.servers.test.ts | 4 +- src/store/authStoreRehydrate.ts | 13 +++---- 7 files changed, 94 insertions(+), 56 deletions(-) diff --git a/src/features/home/pages/homeFeedLoader.ts b/src/features/home/pages/homeFeedLoader.ts index 57ec0253..d293a6b1 100644 --- a/src/features/home/pages/homeFeedLoader.ts +++ b/src/features/home/pages/homeFeedLoader.ts @@ -7,6 +7,7 @@ import { } from '@/lib/api/library/scopeReads'; import { albumToAlbum } from '@/lib/library/advancedSearchLocal'; import { runLocalRandomArtists, runLocalRandomSongs } from '@/lib/library/browseTextSearch'; +import { deriveLibraryBrowseServerIdsWithFallback } from '@/lib/library/libraryBrowseScope'; import { shuffleArray } from '@/lib/util/shuffleArray'; import type { HomeFeedOffsets, HomeFeedSnapshot } from '@/features/home/store/homeFeedCache'; @@ -133,10 +134,7 @@ const mainstageFeeds = { } as const; export function deriveHomeFeedScope(source: HomeScopeSource): HomeFeedScope { - const selected = new Set(source.libraryBrowseServerIds); - const serverIds = source.libraryBrowseServerIds.length === 0 - ? (source.activeServerId ? [source.activeServerId] : []) - : source.servers.filter(server => selected.has(server.id)).map(server => server.id); + const serverIds = deriveLibraryBrowseServerIdsWithFallback(source); const scopeKey = JSON.stringify(serverIds.map(serverId => [ serverId, source.libraryBrowseSelectionByServer[serverId] ?? [], diff --git a/src/lib/library/libraryBrowseScope.test.ts b/src/lib/library/libraryBrowseScope.test.ts index 336de686..e7f1f008 100644 --- a/src/lib/library/libraryBrowseScope.test.ts +++ b/src/lib/library/libraryBrowseScope.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { useAuthStore } from '@/store/authStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; -import { getLibraryBrowseScope } from './libraryBrowseScope'; +import { deriveLibraryBrowseScope, getLibraryBrowseScope } from './libraryBrowseScope'; beforeEach(resetAuthStore); @@ -28,8 +28,39 @@ describe('getLibraryBrowseScope', () => { { serverId: 'a', libraryId: 'a1' }, { serverId: 'b', libraryId: 'b1' }, ], - fingerprint: 'a:a2|a:a1|b:b1', + fingerprint: JSON.stringify([['a', ['a2', 'a1']], ['b', ['b1']]]), multiServer: true, }); }); + + it('keeps the persisted server priority independent of the active connection', () => { + const scope = deriveLibraryBrowseScope({ + servers: [{ id: 'primary' }, { id: 'active' }], + activeServerId: 'active', + libraryBrowseServerIds: ['primary', 'active'], + musicFoldersByServer: {}, + libraryBrowseSelectionByServer: {}, + }); + + expect(scope.anchorServerId).toBe('primary'); + expect(scope.multiServer).toBe(true); + expect(scope.fingerprint).toBe(JSON.stringify([['primary', []], ['active', []]])); + }); + + it('falls back defensively when persisted membership has no valid server', () => { + const scope = deriveLibraryBrowseScope({ + servers: [{ id: 'first' }, { id: 'active' }], + activeServerId: 'active', + libraryBrowseServerIds: ['missing'], + musicFoldersByServer: { active: [{ id: 'music' }] }, + libraryBrowseSelectionByServer: {}, + }); + + expect(scope).toEqual({ + anchorServerId: 'active', + pairs: [], + fingerprint: '', + multiServer: false, + }); + }); }); diff --git a/src/lib/library/libraryBrowseScope.ts b/src/lib/library/libraryBrowseScope.ts index ad7b73e7..53105f03 100644 --- a/src/lib/library/libraryBrowseScope.ts +++ b/src/lib/library/libraryBrowseScope.ts @@ -31,29 +31,56 @@ export interface LibraryBrowseScope { multiServer: boolean; } +type LibraryBrowseServerOrderSource = Pick< + LibraryBrowseScopeSource, + 'servers' | 'activeServerId' | 'libraryBrowseServerIds' +>; + +export function deriveOrderedLibraryBrowseServerIds( + state: LibraryBrowseServerOrderSource, +): string[] { + const selectedServers = new Set(state.libraryBrowseServerIds); + return state.servers + .filter(server => selectedServers.has(server.id)) + .map(server => server.id); +} + +export function deriveLibraryBrowseServerIdsWithFallback( + state: LibraryBrowseServerOrderSource, +): string[] { + const orderedServerIds = deriveOrderedLibraryBrowseServerIds(state); + if (orderedServerIds.length > 0 || state.servers.length === 0) return orderedServerIds; + + const fallback = state.servers.find(server => server.id === state.activeServerId) ?? state.servers[0]; + return fallback ? [fallback.id] : []; +} + /** Ordered concrete source pairs used only by Library pages and search. */ export function deriveLibraryBrowseScope(state: LibraryBrowseScopeSource): LibraryBrowseScope { - const selectedServers = new Set(state.libraryBrowseServerIds); - const orderedServers = state.servers.filter(server => selectedServers.has(server.id)); + const orderedServerIds = deriveOrderedLibraryBrowseServerIds(state); const pairs: LibraryBrowseScopePair[] = []; + const fingerprintEntries: Array<[string, string[]]> = []; - for (const server of orderedServers) { - const folders = state.musicFoldersByServer[server.id] ?? []; - const selection = state.libraryBrowseSelectionByServer[server.id] ?? []; + for (const serverId of orderedServerIds) { + const folders = state.musicFoldersByServer[serverId] ?? []; + const selection = state.libraryBrowseSelectionByServer[serverId] ?? []; const libraryIds = selection.length > 0 ? selection : folders.map(folder => folder.id); + fingerprintEntries.push([serverId, libraryIds]); for (const libraryId of libraryIds) { if (!libraryId) continue; - pairs.push({ serverId: server.id, libraryId }); + pairs.push({ serverId, libraryId }); } } return { - anchorServerId: orderedServers[0]?.id ?? state.activeServerId, + anchorServerId: orderedServerIds[0] + ?? deriveLibraryBrowseServerIdsWithFallback(state)[0] + ?? null, pairs, - fingerprint: pairs.map(pair => `${pair.serverId}:${pair.libraryId}`).join('|'), - multiServer: orderedServers.length > 1, + fingerprint: fingerprintEntries.length > 0 ? JSON.stringify(fingerprintEntries) : '', + multiServer: orderedServerIds.length > 1, }; } diff --git a/src/store/authMusicLibraryActions.ts b/src/store/authMusicLibraryActions.ts index c5230296..d852028e 100644 --- a/src/store/authMusicLibraryActions.ts +++ b/src/store/authMusicLibraryActions.ts @@ -4,6 +4,7 @@ import { runMusicLibraryCatalogReloadHandler, scheduleMusicLibraryFilterVersionBump, } from './musicLibraryFilterNotify'; +import { deriveLibraryBrowseServerIdsWithFallback } from '@/lib/library/libraryBrowseScope'; type SetState = ( partial: Partial | ((state: AuthState) => Partial), @@ -37,11 +38,6 @@ function collapseServerSelection(folders: MusicFolder[], libraryIds: string[]): return folders.every(folder => selected.has(folder.id)) ? [] : libraryIds; } -function selectedServerIdsInOrder(state: AuthState, ids: Iterable): string[] { - const selected = new Set(ids); - return state.servers.filter(server => selected.has(server.id)).map(server => server.id); -} - function deferMusicLibraryCatalogReload(get: GetState, set: SetState, serverId: string): void { // `indexEnabled` is read here in the store layer and handed to the registered // catalog-reload handler so the store never imports `src/lib/library` browse @@ -175,7 +171,11 @@ export function createMusicLibraryActions(set: SetState, get: GetState): Pick< if (selected) current.add(serverId); else current.delete(serverId); if (current.size === 0 && s.servers.length > 0) return; - const next = selectedServerIdsInOrder(s, current); + const next = deriveLibraryBrowseServerIdsWithFallback({ + servers: s.servers, + activeServerId: s.activeServerId, + libraryBrowseServerIds: [...current], + }); if (next.length === s.libraryBrowseServerIds.length && next.every((id, index) => id === s.libraryBrowseServerIds[index])) return; set(state => ({ diff --git a/src/store/authServerProfileActions.ts b/src/store/authServerProfileActions.ts index 734d3599..9dd8740e 100644 --- a/src/store/authServerProfileActions.ts +++ b/src/store/authServerProfileActions.ts @@ -2,23 +2,12 @@ import type { AuthState } from './authStoreTypes'; import { generateId } from './authStoreHelpers'; import { getQueueServerId, clearQueueServerForPlayback } from './playbackEngineBridge'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; +import { deriveLibraryBrowseServerIdsWithFallback } from '@/lib/library/libraryBrowseScope'; type SetState = ( partial: Partial | ((state: AuthState) => Partial), ) => void; -function selectedServerIdsInOrder( - servers: AuthState['servers'], - selectedIds: readonly string[], - fallbackId: string | null, -): string[] { - const selected = new Set(selectedIds); - const ordered = servers.filter(server => selected.has(server.id)).map(server => server.id); - if (ordered.length > 0 || servers.length === 0) return ordered; - const fallback = servers.find(server => server.id === fallbackId) ?? servers[0]; - return fallback ? [fallback.id] : []; -} - /** * Server profile + connection lifecycle. `removeServer` is the * non-trivial one: when the active server is the one being removed it @@ -79,11 +68,11 @@ export function createServerProfileActions(set: SetState): Pick< servers: newServers, activeServerId, isLoggedIn: switchedAway ? false : s.isLoggedIn, - libraryBrowseServerIds: selectedServerIdsInOrder( - newServers, - s.libraryBrowseServerIds.filter(serverId => serverId !== id), + libraryBrowseServerIds: deriveLibraryBrowseServerIdsWithFallback({ + servers: newServers, activeServerId, - ), + libraryBrowseServerIds: s.libraryBrowseServerIds.filter(serverId => serverId !== id), + }), musicFolders: switchedAway && activeServerId ? (foldersRest[activeServerId] ?? []) : s.musicFolders, @@ -103,22 +92,16 @@ export function createServerProfileActions(set: SetState): Pick< setServers: (servers) => set(s => ({ servers, - libraryBrowseServerIds: selectedServerIdsInOrder( + libraryBrowseServerIds: deriveLibraryBrowseServerIdsWithFallback({ servers, - s.libraryBrowseServerIds, - s.activeServerId, - ), + activeServerId: s.activeServerId, + libraryBrowseServerIds: s.libraryBrowseServerIds, + }), libraryBrowseScopeVersion: s.libraryBrowseScopeVersion + 1, })), setActiveServer: (id) => set(s => ({ activeServerId: id, musicFolders: s.musicFoldersByServer[id] ?? [], - ...(s.libraryBrowseServerIds.length <= 1 - ? { - libraryBrowseServerIds: [id], - libraryBrowseScopeVersion: s.libraryBrowseScopeVersion + 1, - } - : {}), })), setLoggedIn: (v) => set({ isLoggedIn: v }), setConnecting: (v) => set({ isConnecting: v }), diff --git a/src/store/authStore.servers.test.ts b/src/store/authStore.servers.test.ts index 3930e35b..79c78318 100644 --- a/src/store/authStore.servers.test.ts +++ b/src/store/authStore.servers.test.ts @@ -87,13 +87,13 @@ describe('setActiveServer', () => { expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([a, b]); }); - it('moves a singleton Library scope with the active server', () => { + it('keeps a singleton Library scope independent of the active server', () => { const { a, b } = addThree(); useAuthStore.setState({ activeServerId: a, libraryBrowseServerIds: [a] }); useAuthStore.getState().setActiveServer(b); expect(useAuthStore.getState().activeServerId).toBe(b); - expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([b]); + expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([a]); }); it('preserves an explicit multi-server Library scope when active server changes', () => { diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index 13645e2b..4e1cf58d 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -28,6 +28,7 @@ import type { WindowButtonStyle, } from './authStoreTypes'; import { migrateLegacyLastfm, sanitizeAccounts } from '../music-network'; +import { deriveLibraryBrowseServerIdsWithFallback } from '@/lib/library/libraryBrowseScope'; /** * Computes the post-rehydration patch for the auth store. Runs all @@ -265,13 +266,11 @@ export function computeAuthStoreRehydration(state: AuthState): Partial typeof id === 'string' && serverIds.has(id)) : [], ); - let libraryBrowseServerIds = state.servers - .filter(server => selectedBrowseIds.has(server.id)) - .map(server => server.id); - if (libraryBrowseServerIds.length === 0 && state.servers.length > 0) { - const fallback = state.servers.find(server => server.id === state.activeServerId) ?? state.servers[0]; - libraryBrowseServerIds = fallback ? [fallback.id] : []; - } + const libraryBrowseServerIds = deriveLibraryBrowseServerIdsWithFallback({ + servers: state.servers, + activeServerId: state.activeServerId, + libraryBrowseServerIds: [...selectedBrowseIds], + }); const rawFoldersByServer = (state as { musicFoldersByServer?: unknown }).musicFoldersByServer; const musicFoldersByServer = Object.fromEntries( Object.entries(rawFoldersByServer && typeof rawFoldersByServer === 'object' ? rawFoldersByServer : {})