fix(browse): album text search union, scope, and local-first index

Merge album/FTS/track sources for title search (any-word LIKE + prefix FTS),
dedupe cluster text hits by album id instead of album_key, route scoped
cluster browse through per-server advanced search, and prefer the local
index over network race when the library index is enabled.
This commit is contained in:
Maxim Isaev
2026-06-05 22:20:41 +03:00
parent e640d14a0e
commit 0feb54ac33
9 changed files with 743 additions and 133 deletions
+40 -10
View File
@@ -3,18 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const runLocalBrowseAlbums = vi.fn();
const runNetworkBrowseAlbums = vi.fn();
const raceBrowseWithLocalFallback = vi.fn();
const isClusterMultiLibraryScopeBrowse = vi.fn();
const isClusterMode = 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/clusterScope', () => ({
isClusterMode: () => isClusterMode(),
}));
vi.mock('../utils/serverCluster/clusterLibraryScopes', () => ({
isClusterMultiLibraryScopeBrowse: () => isClusterMultiLibraryScopeBrowse(),
}));
@@ -24,16 +26,13 @@ import { useBrowseAlbumTextSearch } from './useBrowseAlbumTextSearch';
beforeEach(() => {
vi.clearAllMocks();
isClusterMultiLibraryScopeBrowse.mockReturnValue(false);
isClusterMode.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', () => {
@@ -50,20 +49,51 @@ describe('useBrowseAlbumTextSearch', () => {
});
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 () => {
it('uses local index only in cluster mode', async () => {
isClusterMode.mockReturnValue(true);
const { result } = renderHook(() =>
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
);
await waitFor(() => {
expect(result.current.textSearchLoading).toBe(false);
expect(raceBrowseWithLocalFallback).toHaveBeenCalled();
expect(runLocalBrowseAlbums).toHaveBeenCalled();
});
expect(runNetworkBrowseAlbums).not.toHaveBeenCalled();
});
it('prefers local index when enabled and falls back to network only when local is unavailable', async () => {
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, false);
expect(runNetworkBrowseAlbums).not.toHaveBeenCalled();
expect(result.current.textSearchAlbums![0].id).toBe('al-1');
});
it('falls back to network when the local index cannot serve the query', async () => {
runLocalBrowseAlbums.mockResolvedValueOnce(null);
const { result } = renderHook(() =>
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
);
await waitFor(() => {
expect(result.current.textSearchLoading).toBe(false);
expect(runNetworkBrowseAlbums).toHaveBeenCalled();
});
expect(result.current.textSearchAlbums![0].id).toBe('net-1');
+14 -24
View File
@@ -3,12 +3,11 @@ import { useEffect, useRef, useState } from 'react';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsAlbums,
raceBrowseWithLocalFallback,
runLocalBrowseAlbums,
runNetworkBrowseAlbums,
} from '../utils/library/browseTextSearch';
import { isClusterMultiLibraryScopeBrowse } from '../utils/serverCluster/clusterLibraryScopes';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
/**
* Debounced album title search with local-vs-network race when the
@@ -53,33 +52,24 @@ export function useBrowseAlbumTextSearch(
return;
}
if (isClusterMultiLibraryScopeBrowse()) {
const albums = await runLocalBrowseAlbums(
serverId,
q,
undefined,
losslessOnly,
true,
);
if (isStale()) return;
setTextSearchAlbums(albums ?? []);
const skipReadyCheck = isClusterMode() || isClusterMultiLibraryScopeBrowse();
const localAlbums = await runLocalBrowseAlbums(
serverId,
q,
undefined,
losslessOnly,
skipReadyCheck,
);
if (isStale()) return;
if (localAlbums != null) {
setTextSearchAlbums(localAlbums);
setTextSearchLoading(false);
return;
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
() => runNetworkBrowseAlbums(q),
{
surface: 'albums_browse',
query: q,
indexEnabled,
counts: browseRaceCountsAlbums,
},
);
const networkAlbums = await runNetworkBrowseAlbums(q);
if (isStale()) return;
setTextSearchAlbums(outcome?.result ?? null);
setTextSearchAlbums(networkAlbums);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, serverId, losslessOnly, musicLibraryFilterVersion]);
@@ -10,6 +10,7 @@ import {
trackToSong,
tryRunLocalAdvancedSearch,
} from './advancedSearchLocal';
import { invalidateLibraryReadyCache } from './libraryReady';
import * as albumBrowseNetwork from './albumBrowseNetwork';
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
@@ -38,6 +39,8 @@ const ready = () =>
describe('runLocalAdvancedSearch', () => {
beforeEach(() => {
useLibraryIndexStore.setState({ masterEnabled: true });
invalidateLibraryReadyCache();
useAuthStore.setState({ activeClusterId: null, clusters: [] });
});
it('returns null (→ network fallback) when the index is not ready', async () => {
@@ -52,6 +55,30 @@ describe('runLocalAdvancedSearch', () => {
expect(res).toBeNull();
});
it('uses single-server advanced search when cluster scope narrows to one member', async () => {
useAuthStore.setState({
activeClusterId: 'cluster-1',
clusters: [{ id: 'cluster-1', name: 'C', serverIds: ['s1', 's2'] }],
musicLibraryFilterByServer: { s1: ['lib7'] },
});
ready();
let captured: unknown;
onInvoke('library_advanced_search', (args) => {
captured = args;
return {
artists: [],
albums: [],
tracks: [],
totals: { artists: 0, albums: 0, tracks: 0 },
source: 'local',
};
});
await runLocalAdvancedSearch('s1', opts({ query: 'metallica', resultType: 'albums' }), 100);
expect(captured).toMatchObject({
request: { serverId: expect.any(String), libraryScopeIds: ['lib7'] },
});
});
it('passes libraryScope from the sidebar music library filter', async () => {
useAuthStore.setState({ musicLibraryFilterByServer: { s1: ['lib7'] } });
ready();
+16 -1
View File
@@ -37,7 +37,8 @@ import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
import { clusterAdvancedSearchLocal } from './clusterAdvancedSearchLocal';
import { isClusterMode } from '../serverCluster/clusterScope';
import { narrowedClusterMemberIds } from '../serverCluster/clusterAlbumBrowseMembers';
import { getActiveClusterMemberIds, isClusterMode } from '../serverCluster/clusterScope';
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
@@ -324,6 +325,20 @@ export async function runLocalAdvancedSearch(
);
const run = async () => {
if (isClusterMode()) {
// Mirror All Albums browse: one narrowed member → per-server SQL scope
// (`libraryScopeIds` on that member), not cluster merge without scopes.
const narrowed = narrowedClusterMemberIds(getActiveClusterMemberIds());
if (narrowed.length === 1) {
const scopedReq = buildRequest(
narrowed[0]!,
opts,
entityTypesFor(opts.resultType),
songsLimit,
0,
skipTotals,
);
return libraryAdvancedSearch(scopedReq);
}
return await clusterAdvancedSearchLocal({
query: req.query,
entityTypes: req.entityTypes,
+10 -3
View File
@@ -130,9 +130,16 @@ export function filterAlbumsByNameTextQuery(
albums: SubsonicAlbum[],
query: string,
): SubsonicAlbum[] {
const needle = query.trim().toLowerCase();
if (!needle) return albums;
return albums.filter(a => a.name.toLowerCase().includes(needle));
const tokens = query
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (tokens.length === 0) return albums;
return albums.filter(a => {
const name = a.name.toLowerCase();
return tokens.some(token => name.includes(token));
});
}
export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
+2 -1
View File
@@ -168,9 +168,10 @@ describe('filterAlbumsByNameTextQuery', () => {
{ id: '3', name: 'Random Title', artist: 'Abbey Road Band', artistId: 'b', songCount: 1, duration: 1 },
];
it('matches album title only, not artist name', () => {
it('matches any query word in album title only, not artist name', () => {
expect(filterAlbumsByNameTextQuery(albums, 'abbey').map(a => a.id)).toEqual(['1']);
expect(filterAlbumsByNameTextQuery(albums, 'beatles').map(a => a.id)).toEqual(['2']);
expect(filterAlbumsByNameTextQuery(albums, 'road abbey').map(a => a.id)).toEqual(['1']);
});
});