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
+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();
});
});
}