mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user