mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
91e7195e0f
* 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
233 lines
6.6 KiB
TypeScript
233 lines
6.6 KiB
TypeScript
/**
|
|
* Parallel local vs network search — first successful backend wins.
|
|
*/
|
|
|
|
import type { SearchResults } from '../../api/subsonicTypes';
|
|
|
|
export type SearchRaceSource = 'local' | 'network';
|
|
|
|
export interface SearchRaceWinner<T> {
|
|
source: SearchRaceSource;
|
|
result: T;
|
|
durationMs: number;
|
|
}
|
|
|
|
export interface SearchRaceRunner<T> {
|
|
source: SearchRaceSource;
|
|
run: () => Promise<T | null>;
|
|
}
|
|
|
|
/**
|
|
* Run search backends in parallel. The first non-null result wins; one runner
|
|
* failing does not reject until every runner has failed or returned null.
|
|
*/
|
|
export async function raceSearchSources<T>(
|
|
runners: SearchRaceRunner<T>[],
|
|
isStale: () => boolean,
|
|
): Promise<SearchRaceWinner<T> | null> {
|
|
if (runners.length === 0 || isStale()) return null;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let pending = runners.length;
|
|
let settled = false;
|
|
const errors: unknown[] = [];
|
|
|
|
const onRunnerDone = () => {
|
|
pending -= 1;
|
|
if (!settled && pending === 0) {
|
|
if (errors.length > 0) reject(errors[0]);
|
|
else resolve(null);
|
|
}
|
|
};
|
|
|
|
for (const { source, run } of runners) {
|
|
const t0 = performance.now();
|
|
void run()
|
|
.then(result => {
|
|
if (settled) return;
|
|
if (isStale()) {
|
|
onRunnerDone();
|
|
return;
|
|
}
|
|
if (result != null) {
|
|
settled = true;
|
|
resolve({
|
|
source,
|
|
result,
|
|
durationMs: Math.round(performance.now() - t0),
|
|
});
|
|
return;
|
|
}
|
|
onRunnerDone();
|
|
})
|
|
.catch(err => {
|
|
if (settled) return;
|
|
if (isStale()) {
|
|
onRunnerDone();
|
|
return;
|
|
}
|
|
errors.push(err);
|
|
onRunnerDone();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
}
|