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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user