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
+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,