mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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