fix(library): stabilize primary browse server

This commit is contained in:
cucadmuh
2026-07-18 04:30:16 +03:00
parent 21e8014b9a
commit 6856ede197
7 changed files with 94 additions and 56 deletions
+2 -4
View File
@@ -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] ?? [],
+33 -2
View File
@@ -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,
});
});
});
+36 -9
View File
@@ -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,
};
}
+6 -6
View File
@@ -4,6 +4,7 @@ import {
runMusicLibraryCatalogReloadHandler,
scheduleMusicLibraryFilterVersionBump,
} from './musicLibraryFilterNotify';
import { deriveLibraryBrowseServerIdsWithFallback } from '@/lib/library/libraryBrowseScope';
type SetState = (
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
@@ -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>): 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 => ({
+9 -26
View File
@@ -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<AuthState> | ((state: AuthState) => Partial<AuthState>),
) => 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 }),
+2 -2
View File
@@ -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', () => {
+6 -7
View File
@@ -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<AuthState
? rawBrowseServerIds.filter((id): id is string => 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 : {})