fix(library): scoped live search FTS, race, and multi-server advanced search (#868)

* fix(library): scoped live search FTS, race, and multi-server advanced search

Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.

Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.

* docs: CHANGELOG and credits for PR #868 live search fix
This commit is contained in:
cucadmuh
2026-05-25 04:20:02 +03:00
committed by GitHub
parent bc85065316
commit 91e7195e0f
13 changed files with 839 additions and 207 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ export async function search(
artistCount?: number;
songCount?: number;
signal?: AbortSignal;
timeout?: number;
},
): Promise<SearchResults> {
if (!query.trim()) return { artists: [], albums: [], songs: [] };
@@ -40,7 +41,7 @@ export async function search(
songCount: options?.songCount ?? 10,
...libraryFilterParams(),
},
15000,
options?.timeout ?? 15000,
options?.signal,
);
const r = data.searchResult3 ?? {};
+51 -12
View File
@@ -7,11 +7,17 @@ import {
LIVE_SEARCH_DEBOUNCE_RACE_MS,
EMPTY_SEARCH_RESULTS,
liveSearchQueryTooShort,
mergeLiveSearchResults,
runLocalLiveSearch,
runNetworkLiveSearch,
} from '../utils/library/liveSearchLocal';
import { raceSearchSources } from '../utils/library/searchRace';
import { raceLiveSearch } from '../utils/library/searchRace';
import { libraryIsReady } from '../utils/library/libraryReady';
import {
emitLiveSearchDebug,
searchHitCounts,
searchResultSamples,
} from '../utils/library/liveSearchDebug';
import {
logLibrarySearch,
} from '../utils/library/libraryDevLog';
@@ -172,24 +178,57 @@ export default function LiveSearch() {
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
if (indexEnabled && serverId) {
const winner = await raceSearchSources(
[
{
source: 'local',
run: () => runLocalLiveSearch(serverId, q, raceCtx),
},
{
source: 'network',
run: () => runNetworkLiveSearch(q, abort.signal),
},
],
const winner = await raceLiveSearch(
() => runLocalLiveSearch(serverId, q, raceCtx),
() => runNetworkLiveSearch(q, abort.signal),
isStale,
meta => {
emitLiveSearchDebug('race_settled', {
query: q,
winner: meta.winner,
localMs: meta.localMs,
networkMs: meta.networkMs,
localHits: meta.localHits,
networkHits: meta.networkHits,
});
if (isStale()) return;
if (meta.localResult && meta.networkResult) {
const primary =
meta.winner === 'local' ? meta.localResult : meta.networkResult;
const supplement =
meta.winner === 'local' ? meta.networkResult : meta.localResult;
const merged = mergeLiveSearchResults(primary, supplement);
const primaryHits = searchHitCounts(primary);
const mergedHits = searchHitCounts(merged);
if (mergedHits !== primaryHits) {
setResults(merged);
setSearchSource(meta.winner);
emitLiveSearchDebug('race_merged', {
query: q,
winner: meta.winner,
before: primaryHits,
after: mergedHits,
samples: searchResultSamples(merged),
});
}
}
},
);
if (isStale()) return;
if (winner) {
setResults(winner.result);
setSearchSource(winner.source);
setOpen(true);
const samples = searchResultSamples(winner.result);
emitLiveSearchDebug('race_winner', {
query: q,
winner: winner.source,
raceMs: winner.durationMs,
hits: searchHitCounts(winner.result),
samples,
path: 'search_race',
localReady: localReadyRef.current,
});
logLibrarySearch({
at: new Date().toISOString(),
query: q,
+1
View File
@@ -129,6 +129,7 @@ const CONTRIBUTOR_ENTRIES = [
'Local library index: full resync orphan sweep (IS-7) — remove server-deleted tracks after successful re-sync (PR #861)',
'Track enrichment: oximedia BPM/mood analysis, mood-group Advanced Search, queue display, unified playback analysis dispatch (PR #863)',
'Server index-key rebuild follow-up: startup-safe migration orchestration, per-server analysis strategy controls, playback/cache scope hardening, and backup/restore for library databases with blocking progress UX (PR #864)',
'Live Search: server-scoped local FTS, multi-server hit fix, and local vs search3 race merge (PR #868)',
],
},
{
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { emitLiveSearchDebug } from './liveSearchDebug';
describe('emitLiveSearchDebug', () => {
beforeEach(() => {
useAuthStore.setState({ loggingMode: 'normal' });
});
it('forwards JSON to frontend_debug_log in debug mode', () => {
useAuthStore.setState({ loggingMode: 'debug' });
let captured: unknown;
onInvoke('frontend_debug_log', args => {
captured = args;
return undefined;
});
emitLiveSearchDebug('race_winner', { query: 'metal', winner: 'network' });
expect(captured).toEqual({
scope: 'live-search',
message: JSON.stringify({
step: 'race_winner',
details: { query: 'metal', winner: 'network' },
}),
});
});
it('is a no-op when logging mode is not debug', () => {
let invoked = false;
onInvoke('frontend_debug_log', () => {
invoked = true;
return undefined;
});
emitLiveSearchDebug('race_winner', { query: 'x' });
expect(invoked).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { invoke } from '@tauri-apps/api/core';
import type { SearchResults } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
export function searchHitCounts(result: SearchResults): string {
return `${result.artists.length}/${result.albums.length}/${result.songs.length}`;
}
export function searchResultSamples(result: SearchResults, max = 2) {
return {
artists: result.artists.slice(0, max).map(a => a.name),
albums: result.albums.slice(0, max).map(a => a.name),
songs: result.songs.slice(0, max).map(s => s.title),
};
}
/**
* Settings → Logging → **Debug** → Rust debug log file (`frontend_debug_log`).
* Same transport as normalization / lucky-mix / orbit.
*/
export function emitLiveSearchDebug(step: string, details?: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'live-search',
message: JSON.stringify({ step, details }),
}).catch(() => {});
}
+21
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import type { SearchResults } from '../../api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import {
liveSearchQueryTooShort,
mergeLiveSearchResults,
runLocalLiveSearch,
} from './liveSearchLocal';
@@ -96,3 +98,22 @@ describe('liveSearchQueryTooShort', () => {
expect(liveSearchQueryTooShort('ab')).toBe(false);
});
});
describe('mergeLiveSearchResults', () => {
it('keeps local order and fills gaps from network', () => {
const local: SearchResults = {
artists: [{ id: 'a1', name: 'Local' }],
albums: [],
songs: [{ id: 's1', title: 'Song', artist: 'A', album: 'Al', albumId: 'al0', duration: 1 }],
};
const network: SearchResults = {
artists: [{ id: 'a2', name: 'Net' }],
albums: [{ id: 'al1', name: 'Album', artist: 'A', artistId: 'a2', songCount: 1, duration: 100 }],
songs: [{ id: 's2', title: 'Other', artist: 'B', album: 'Bl', albumId: 'al1', duration: 2 }],
};
const merged = mergeLiveSearchResults(local, network);
expect(merged.artists.map(a => a.id)).toEqual(['a1', 'a2']);
expect(merged.albums.map(a => a.id)).toEqual(['al1']);
expect(merged.songs.map(s => s.id)).toEqual(['s1', 's2']);
});
});
+44 -1
View File
@@ -20,6 +20,15 @@ export const LIVE_SEARCH_DEBOUNCE_NETWORK_MS = 300;
/** Debounce when local + network run in parallel. */
export const LIVE_SEARCH_DEBOUNCE_RACE_MS = 200;
/** search3 timeout for Live Search network arm (race must not hang on slow servers). */
export const LIVE_SEARCH_NETWORK_TIMEOUT_MS = 8000;
export const LIVE_SEARCH_LIMITS = {
artists: 5,
albums: 5,
songs: 10,
} as const;
/** Local FTS skipped below this length — see `LOCAL_FTS_MIN_QUERY_CHARS` in Rust. */
export const LOCAL_FTS_MIN_QUERY_CHARS = 2;
@@ -124,10 +133,44 @@ export async function runNetworkLiveSearch(
const q = query.trim();
if (liveSearchQueryTooShort(q)) return null;
try {
return await search(q, { signal });
return await search(q, {
signal,
timeout: LIVE_SEARCH_NETWORK_TIMEOUT_MS,
artistCount: LIVE_SEARCH_LIMITS.artists,
albumCount: LIVE_SEARCH_LIMITS.albums,
songCount: LIVE_SEARCH_LIMITS.songs,
});
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return null;
throw err;
}
}
/** Keep local ordering; fill remaining slots from network when search3 returns more hits. */
export function mergeLiveSearchResults(
primary: SearchResults,
supplement: SearchResults,
limits: { artists: number; albums: number; songs: number } = LIVE_SEARCH_LIMITS,
): SearchResults {
const mergeSlice = <T extends { id: string }>(
primaryItems: T[],
extraItems: T[],
limit: number,
): T[] => {
const seen = new Set(primaryItems.map(i => i.id));
const out = [...primaryItems];
for (const item of extraItems) {
if (out.length >= limit) break;
if (seen.has(item.id)) continue;
seen.add(item.id);
out.push(item);
}
return out;
};
return {
artists: mergeSlice(primary.artists, supplement.artists, limits.artists),
albums: mergeSlice(primary.albums, supplement.albums, limits.albums),
songs: mergeSlice(primary.songs, supplement.songs, limits.songs),
};
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import type { SearchResults } from '../../api/subsonicTypes';
import { raceLiveSearch, type LiveSearchRaceSettled } from './searchRace';
const empty: SearchResults = { artists: [], albums: [], songs: [] };
const localHits: SearchResults = {
artists: [{ id: 'a1', name: 'Local' }],
albums: [],
songs: [],
};
const networkHits: SearchResults = {
artists: [{ id: 'a2', name: 'Network' }],
albums: [],
songs: [],
};
describe('raceLiveSearch', () => {
it('network wins when it returns hits first', async () => {
const winner = await raceLiveSearch(
() =>
new Promise<SearchResults | null>(resolve => {
setTimeout(() => resolve(localHits), 40);
}),
async () => networkHits,
() => false,
);
expect(winner?.source).toBe('network');
});
it('waits for network when local is empty', async () => {
const winner = await raceLiveSearch(
async () => empty,
async () => networkHits,
() => false,
);
expect(winner?.source).toBe('network');
});
it('local wins when network is empty and local has hits', async () => {
const winner = await raceLiveSearch(
async () => localHits,
async () => empty,
() => false,
);
expect(winner?.source).toBe('local');
});
it('waits for local when network is empty', async () => {
const winner = await raceLiveSearch(
() =>
new Promise<SearchResults | null>(resolve => {
setTimeout(() => resolve(localHits), 30);
}),
async () => empty,
() => false,
);
expect(winner?.source).toBe('local');
});
it('does not pick empty local before network returns hits', async () => {
const winner = await raceLiveSearch(
async () => empty,
() =>
new Promise<SearchResults | null>(resolve => {
setTimeout(() => resolve(networkHits), 30);
}),
() => false,
);
expect(winner?.source).toBe('network');
});
it('calls onSettled with both runner timings', async () => {
let settled: LiveSearchRaceSettled | null = null;
await raceLiveSearch(
async () => localHits,
async () => networkHits,
() => false,
meta => {
settled = meta;
},
);
await new Promise<void>(resolve => {
setTimeout(resolve, 0);
});
expect(settled).not.toBeNull();
expect(settled!.localHits).toBe('1/0/0');
expect(settled!.networkHits).toBe('1/0/0');
expect(settled!.localMs).toBeGreaterThanOrEqual(0);
expect(settled!.networkMs).toBeGreaterThanOrEqual(0);
});
});
+160
View File
@@ -2,6 +2,8 @@
* Parallel local vs network search — first successful backend wins.
*/
import type { SearchResults } from '../../api/subsonicTypes';
export type SearchRaceSource = 'local' | 'network';
export interface SearchRaceWinner<T> {
@@ -70,3 +72,161 @@ export async function raceSearchSources<T>(
}
});
}
export function searchResultsHaveHits(results: SearchResults): boolean {
return results.artists.length > 0 || results.albums.length > 0 || results.songs.length > 0;
}
export interface LiveSearchRaceSettled {
winner: SearchRaceSource;
localMs: number;
networkMs: number;
localHits: string;
networkHits: string;
localResult: SearchResults | null;
networkResult: SearchResults | null;
}
function hitCounts(r: SearchResults): string {
return `${r.artists.length}/${r.albums.length}/${r.songs.length}`;
}
function emptySearchResults(): SearchResults {
return { artists: [], albums: [], songs: [] };
}
function resultOrEmpty(result: SearchResults | null): SearchResults {
return result ?? emptySearchResults();
}
/**
* Live Search race: first backend with hits wins; empty waits for the other.
* `onSettled` fires when both runners finish and includes both payloads for merge.
*/
export async function raceLiveSearch(
localRun: () => Promise<SearchResults | null>,
networkRun: () => Promise<SearchResults | null>,
isStale: () => boolean,
onSettled?: (meta: LiveSearchRaceSettled) => void,
): Promise<SearchRaceWinner<SearchResults> | null> {
if (isStale()) return null;
return new Promise((resolve, reject) => {
let settled = false;
let resolvedWinner: SearchRaceSource | null = null;
let localResult: SearchResults | null = null;
let networkResult: SearchResults | null = null;
let localDone = false;
let networkDone = false;
let localMs = 0;
let networkMs = 0;
let raceNotified = false;
const errors: unknown[] = [];
const notifySettled = () => {
if (raceNotified || !localDone || !networkDone || !onSettled) return;
raceNotified = true;
const local = resultOrEmpty(localResult);
const network = resultOrEmpty(networkResult);
const winner =
resolvedWinner ??
(searchResultsHaveHits(network)
? 'network'
: searchResultsHaveHits(local)
? 'local'
: networkResult
? 'network'
: 'local');
onSettled({
winner,
localMs,
networkMs,
localHits: hitCounts(local),
networkHits: hitCounts(network),
localResult,
networkResult,
});
};
const resolveWinner = (source: SearchRaceSource, result: SearchResults, durationMs: number) => {
settled = true;
resolvedWinner = source;
resolve({ source, result, durationMs });
};
const maybeFinish = () => {
if (settled || isStale()) return;
if (localDone && localResult && searchResultsHaveHits(localResult)) {
resolveWinner('local', localResult, localMs);
if (networkDone) notifySettled();
return;
}
if (networkDone && networkResult && searchResultsHaveHits(networkResult)) {
resolveWinner('network', networkResult, networkMs);
if (localDone) notifySettled();
return;
}
if (!localDone || !networkDone) return;
settled = true;
if (networkResult && searchResultsHaveHits(networkResult)) {
resolvedWinner = 'network';
resolve({ source: 'network', result: networkResult, durationMs: networkMs });
} else if (localResult && searchResultsHaveHits(localResult)) {
resolvedWinner = 'local';
resolve({ source: 'local', result: localResult, durationMs: localMs });
} else if (networkResult) {
resolvedWinner = 'network';
resolve({ source: 'network', result: networkResult, durationMs: networkMs });
} else if (localResult) {
resolvedWinner = 'local';
resolve({ source: 'local', result: localResult, durationMs: localMs });
} else if (errors.length > 0) {
reject(errors[0]);
} else {
resolve(null);
}
notifySettled();
};
const localT0 = performance.now();
void localRun()
.then(result => {
localMs = Math.round(performance.now() - localT0);
localResult = result;
localDone = true;
maybeFinish();
notifySettled();
})
.catch(err => {
errors.push(err);
localDone = true;
maybeFinish();
notifySettled();
});
const networkT0 = performance.now();
void networkRun()
.then(result => {
networkMs = Math.round(performance.now() - networkT0);
networkResult = result;
networkDone = true;
maybeFinish();
notifySettled();
})
.catch(err => {
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') {
networkDone = true;
maybeFinish();
notifySettled();
return;
}
errors.push(err);
networkDone = true;
maybeFinish();
notifySettled();
});
});
}