mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user