mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(library): stabilize primary browse server
This commit is contained in:
@@ -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] ?? [],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 : {})
|
||||
|
||||
Reference in New Issue
Block a user