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:
Maxim Isaev
2026-06-05 21:26:14 +03:00
parent 6c64c5aa41
commit 38ec62a639
5 changed files with 160 additions and 1 deletions
@@ -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');
});
});
+15
View File
@@ -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),
+2 -1
View File
@@ -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)));
}