mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(browse): cluster multi-library album search without network race
When two or more sidebar libraries are selected in cluster mode, All Albums text search uses the merged local index only so an empty search3 response from one member cannot win the local-vs-network race.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runLocalBrowseAlbums = vi.fn();
|
||||
const runNetworkBrowseAlbums = vi.fn();
|
||||
const raceBrowseWithLocalFallback = vi.fn();
|
||||
const isClusterMultiLibraryScopeBrowse = vi.fn();
|
||||
|
||||
vi.mock('../utils/library/browseTextSearch', () => ({
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS: 0,
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 0,
|
||||
browseRaceCountsAlbums: vi.fn(),
|
||||
raceBrowseWithLocalFallback: (...args: unknown[]) => raceBrowseWithLocalFallback(...args),
|
||||
runLocalBrowseAlbums: (...args: unknown[]) => runLocalBrowseAlbums(...args),
|
||||
runNetworkBrowseAlbums: (...args: unknown[]) => runNetworkBrowseAlbums(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/serverCluster/clusterLibraryScopes', () => ({
|
||||
isClusterMultiLibraryScopeBrowse: () => isClusterMultiLibraryScopeBrowse(),
|
||||
}));
|
||||
|
||||
import { useBrowseAlbumTextSearch } from './useBrowseAlbumTextSearch';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isClusterMultiLibraryScopeBrowse.mockReturnValue(false);
|
||||
runLocalBrowseAlbums.mockResolvedValue([
|
||||
{ id: 'al-1', name: 'Local', artist: 'A', artistId: 'a', songCount: 1, duration: 1 },
|
||||
]);
|
||||
runNetworkBrowseAlbums.mockResolvedValue([
|
||||
{ id: 'net-1', name: 'Net', artist: 'B', artistId: 'b', songCount: 1, duration: 1 },
|
||||
]);
|
||||
raceBrowseWithLocalFallback.mockResolvedValue({
|
||||
source: 'network',
|
||||
result: [{ id: 'net-1', name: 'Net', artist: 'B', artistId: 'b', songCount: 1, duration: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBrowseAlbumTextSearch', () => {
|
||||
it('uses local cluster index only when multi-library scope is active', async () => {
|
||||
isClusterMultiLibraryScopeBrowse.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.textSearchLoading).toBe(false);
|
||||
expect(runLocalBrowseAlbums).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(runLocalBrowseAlbums).toHaveBeenCalledWith('srv-a', 'beatles', undefined, false, true);
|
||||
expect(raceBrowseWithLocalFallback).not.toHaveBeenCalled();
|
||||
expect(runNetworkBrowseAlbums).not.toHaveBeenCalled();
|
||||
expect(result.current.textSearchAlbums).toHaveLength(1);
|
||||
expect(result.current.textSearchAlbums![0].id).toBe('al-1');
|
||||
});
|
||||
|
||||
it('races local and network when cluster multi-library scope is inactive', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.textSearchLoading).toBe(false);
|
||||
expect(raceBrowseWithLocalFallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result.current.textSearchAlbums![0].id).toBe('net-1');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
runLocalBrowseAlbums,
|
||||
runNetworkBrowseAlbums,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { isClusterMultiLibraryScopeBrowse } from '../utils/serverCluster/clusterLibraryScopes';
|
||||
|
||||
/**
|
||||
* Debounced album title search with local-vs-network race when the
|
||||
@@ -52,6 +53,20 @@ export function useBrowseAlbumTextSearch(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isClusterMultiLibraryScopeBrowse()) {
|
||||
const albums = await runLocalBrowseAlbums(
|
||||
serverId,
|
||||
q,
|
||||
undefined,
|
||||
losslessOnly,
|
||||
true,
|
||||
);
|
||||
if (isStale()) return;
|
||||
setTextSearchAlbums(albums ?? []);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
|
||||
|
||||
@@ -266,12 +266,13 @@ export async function runLocalBrowseAlbums(
|
||||
query: string,
|
||||
limit = ALBUM_BROWSE_LIMIT,
|
||||
losslessOnly = false,
|
||||
skipReadyCheck = false,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
albumBrowseOpts(query, losslessOnly),
|
||||
limit,
|
||||
false,
|
||||
skipReadyCheck,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const isClusterMode = vi.fn();
|
||||
const getActiveClusterMemberIds = vi.fn();
|
||||
const musicLibraryFilterForServer = vi.fn();
|
||||
|
||||
vi.mock('./clusterScope', () => ({
|
||||
isClusterMode: () => isClusterMode(),
|
||||
getActiveClusterMemberIds: () => getActiveClusterMemberIds(),
|
||||
}));
|
||||
|
||||
vi.mock('../musicLibraryFilter', () => ({
|
||||
isAllLibrariesFilter: (f: unknown) => f === 'all',
|
||||
isLibraryFolderSelected: vi.fn(),
|
||||
libraryScopeSubtitleFromFolders: vi.fn(),
|
||||
musicLibraryFilterForServer: (sid: string) => musicLibraryFilterForServer(sid),
|
||||
musicLibraryFilterStorageKey: vi.fn(),
|
||||
libraryScopeIdsForServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { isClusterMultiLibraryScopeBrowse } from './clusterLibraryScopes';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isClusterMode.mockReturnValue(true);
|
||||
getActiveClusterMemberIds.mockReturnValue(['srv-a', 'srv-b']);
|
||||
});
|
||||
|
||||
describe('isClusterMultiLibraryScopeBrowse', () => {
|
||||
it('is false outside cluster mode', () => {
|
||||
isClusterMode.mockReturnValue(false);
|
||||
musicLibraryFilterForServer.mockReturnValue(['lib-1', 'lib-2']);
|
||||
expect(isClusterMultiLibraryScopeBrowse()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false with a single scoped folder', () => {
|
||||
musicLibraryFilterForServer.mockImplementation((sid: string) =>
|
||||
sid === 'srv-a' ? ['lib-1'] : 'all',
|
||||
);
|
||||
expect(isClusterMultiLibraryScopeBrowse()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when one member has two folders selected', () => {
|
||||
musicLibraryFilterForServer.mockImplementation((sid: string) =>
|
||||
sid === 'srv-a' ? ['lib-1', 'lib-2'] : 'all',
|
||||
);
|
||||
expect(isClusterMultiLibraryScopeBrowse()).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when two members each have one folder selected', () => {
|
||||
musicLibraryFilterForServer.mockImplementation((sid: string) =>
|
||||
sid === 'srv-a' ? ['lib-1'] : ['lib-2'],
|
||||
);
|
||||
expect(isClusterMultiLibraryScopeBrowse()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,22 @@ export function isClusterLibraryScopeNarrowed(): boolean {
|
||||
return buildClusterLibraryScopes(getActiveClusterMemberIds()) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster + two or more sidebar folders selected (on one or more members).
|
||||
* Browse album text search must use local cluster index only — network search3
|
||||
* targets one server and wins races with empty results.
|
||||
*/
|
||||
export function isClusterMultiLibraryScopeBrowse(): boolean {
|
||||
if (!isClusterMode()) return false;
|
||||
let selected = 0;
|
||||
for (const sid of getActiveClusterMemberIds()) {
|
||||
const f = musicLibraryFilterForServer(sid);
|
||||
if (f === 'all') continue;
|
||||
selected += f.length;
|
||||
}
|
||||
return selected > 1;
|
||||
}
|
||||
|
||||
export function isClusterAllLibrariesSelected(memberIds: string[]): boolean {
|
||||
return memberIds.every(sid => isAllLibrariesFilter(musicLibraryFilterForServer(sid)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user