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
+91
View File
@@ -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);
});
});