From 38ec62a63983d400eeed35fc91a20a835d47b9c8 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 5 Jun 2026 21:26:14 +0300 Subject: [PATCH] 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. --- src/hooks/useBrowseAlbumTextSearch.test.ts | 71 +++++++++++++++++++ src/hooks/useBrowseAlbumTextSearch.ts | 15 ++++ src/utils/library/browseTextSearch.ts | 3 +- .../clusterLibraryScopes.test.ts | 56 +++++++++++++++ .../serverCluster/clusterLibraryScopes.ts | 16 +++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useBrowseAlbumTextSearch.test.ts create mode 100644 src/utils/serverCluster/clusterLibraryScopes.test.ts diff --git a/src/hooks/useBrowseAlbumTextSearch.test.ts b/src/hooks/useBrowseAlbumTextSearch.test.ts new file mode 100644 index 00000000..261fbc3b --- /dev/null +++ b/src/hooks/useBrowseAlbumTextSearch.test.ts @@ -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'); + }); +}); diff --git a/src/hooks/useBrowseAlbumTextSearch.ts b/src/hooks/useBrowseAlbumTextSearch.ts index 7324396e..c9452152 100644 --- a/src/hooks/useBrowseAlbumTextSearch.ts +++ b/src/hooks/useBrowseAlbumTextSearch.ts @@ -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), diff --git a/src/utils/library/browseTextSearch.ts b/src/utils/library/browseTextSearch.ts index 95a27284..6a3cf0c5 100644 --- a/src/utils/library/browseTextSearch.ts +++ b/src/utils/library/browseTextSearch.ts @@ -266,12 +266,13 @@ export async function runLocalBrowseAlbums( query: string, limit = ALBUM_BROWSE_LIMIT, losslessOnly = false, + skipReadyCheck = false, ): Promise { const page = await runLocalAdvancedSearch( serverId, albumBrowseOpts(query, losslessOnly), limit, - false, + skipReadyCheck, true, true, ); diff --git a/src/utils/serverCluster/clusterLibraryScopes.test.ts b/src/utils/serverCluster/clusterLibraryScopes.test.ts new file mode 100644 index 00000000..82e5b574 --- /dev/null +++ b/src/utils/serverCluster/clusterLibraryScopes.test.ts @@ -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); + }); +}); diff --git a/src/utils/serverCluster/clusterLibraryScopes.ts b/src/utils/serverCluster/clusterLibraryScopes.ts index 9c0d284d..51784598 100644 --- a/src/utils/serverCluster/clusterLibraryScopes.ts +++ b/src/utils/serverCluster/clusterLibraryScopes.ts @@ -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))); }