mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(library): browse local index race and catalog paths (#847)
* feat(library): race local index vs network on browse text search Wire Artists, Composers, Tracks, and SearchResults to parallel local FTS and network search3 with graceful fallback when remote fails while the index is ready. * feat(library): local browse for albums/artists and dev race logging Serve All Albums and Artists catalog from the local index when ready, with network fallback. Log browse text-search race outcomes to DevTools (`[psysonic][library] browse-race …`) including winner, timings, and hits. * docs(changelog): note PR #847 browse local index race and catalog paths * refactor(library): unify DevTools search log format Live Search, Advanced Search, and browse races emit one-line `search [surface] …` entries via formatLibrarySearchLine (DEV only).
This commit is contained in:
@@ -234,6 +234,8 @@ export async function runLocalAdvancedSearch(
|
||||
at: new Date().toISOString(),
|
||||
query: opts.query.trim(),
|
||||
path: 'library_advanced_search',
|
||||
surface: 'advanced_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
invokeMs,
|
||||
counts: {
|
||||
@@ -250,6 +252,8 @@ export async function runLocalAdvancedSearch(
|
||||
at: new Date().toISOString(),
|
||||
query: opts.query.trim(),
|
||||
path: 'library_advanced_search',
|
||||
surface: 'advanced_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
error: String(err),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { browseRaceCountsArtists, raceBrowseWithLocalFallback } from './browseTextSearch';
|
||||
|
||||
describe('raceBrowseWithLocalFallback', () => {
|
||||
it('returns local when network throws and local has data', async () => {
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
() => false,
|
||||
async () => [{ id: 'a1', name: 'Local Artist' }],
|
||||
async () => {
|
||||
throw new Error('server down');
|
||||
},
|
||||
{
|
||||
surface: 'artists_browse',
|
||||
query: 'test',
|
||||
counts: browseRaceCountsArtists,
|
||||
},
|
||||
);
|
||||
expect(outcome?.source).toBe('local');
|
||||
expect(outcome?.result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to local after race when network was faster but returned null', async () => {
|
||||
let localCalls = 0;
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
() => false,
|
||||
async () => {
|
||||
localCalls += 1;
|
||||
return localCalls >= 2 ? ['hit'] : null;
|
||||
},
|
||||
async () => null,
|
||||
);
|
||||
expect(outcome?.source).toBe('local');
|
||||
expect(outcome?.result).toEqual(['hit']);
|
||||
});
|
||||
|
||||
it('returns network when local is unavailable', async () => {
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
() => false,
|
||||
async () => null,
|
||||
async () => ['network'],
|
||||
);
|
||||
expect(outcome?.source).toBe('network');
|
||||
expect(outcome?.result).toEqual(['network']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Browse-page text search — local index vs network race (LiveSearch / AdvancedSearch pattern).
|
||||
*/
|
||||
import { search, searchSongsPaged } from '../../api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { libraryAdvancedSearch } from '../../api/library';
|
||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||
import {
|
||||
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
|
||||
LIVE_SEARCH_DEBOUNCE_RACE_MS,
|
||||
} from './liveSearchLocal';
|
||||
import type { LibraryFilterClause, LibrarySortClause } from '../../api/library';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
loadMoreLocalSongs,
|
||||
runLocalAdvancedSearch,
|
||||
runNetworkAdvancedTextSearch,
|
||||
trackToSong,
|
||||
type LocalSearchOpts,
|
||||
} from './advancedSearchLocal';
|
||||
import {
|
||||
logLibrarySearch,
|
||||
timed,
|
||||
type LibrarySearchDebugEntry,
|
||||
type LibrarySearchSurface,
|
||||
} from './libraryDevLog';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
|
||||
|
||||
export type { LibrarySearchSurface };
|
||||
|
||||
export interface BrowseRaceLogOptions {
|
||||
surface: LibrarySearchSurface;
|
||||
query: string;
|
||||
indexEnabled?: boolean;
|
||||
counts?: (result: unknown) => LibrarySearchDebugEntry['counts'];
|
||||
}
|
||||
|
||||
function logBrowseRaceOutcome(
|
||||
log: BrowseRaceLogOptions | undefined,
|
||||
path: LibrarySearchDebugEntry['path'],
|
||||
winner: SearchRaceWinner<unknown> | null,
|
||||
durationMs: number,
|
||||
fallbackReason?: string,
|
||||
): void {
|
||||
if (!log) return;
|
||||
logLibrarySearch({
|
||||
at: new Date().toISOString(),
|
||||
query: log.query,
|
||||
path,
|
||||
durationMs,
|
||||
indexEnabled: log.indexEnabled,
|
||||
surface: log.surface,
|
||||
raceWinner: winner?.source,
|
||||
raceWinnerMs: winner?.durationMs,
|
||||
counts: winner && log.counts ? log.counts(winner.result) : undefined,
|
||||
fallbackReason,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
LIVE_SEARCH_DEBOUNCE_RACE_MS as BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||
LIVE_SEARCH_DEBOUNCE_NETWORK_MS as BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
};
|
||||
|
||||
/** Network arm for browse races — errors become null, never reject the race. */
|
||||
async function safeNetwork<T>(run: () => Promise<T | null>): Promise<T | null> {
|
||||
try {
|
||||
return await run();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel local vs network browse search. Network failures are swallowed. When
|
||||
* the race does not pick a winner (or rejects because local threw), local is
|
||||
* tried again so a down remote server does not block a ready index.
|
||||
*/
|
||||
export async function raceBrowseWithLocalFallback<T>(
|
||||
isStale: () => boolean,
|
||||
local: () => Promise<T | null>,
|
||||
network: () => Promise<T | null>,
|
||||
log?: BrowseRaceLogOptions,
|
||||
): Promise<SearchRaceWinner<T> | null> {
|
||||
if (isStale()) return null;
|
||||
|
||||
const t0 = performance.now();
|
||||
let winner: SearchRaceWinner<T> | null = null;
|
||||
try {
|
||||
winner = await raceSearchSources(
|
||||
[
|
||||
{ source: 'local', run: local },
|
||||
{ source: 'network', run: () => safeNetwork(network) },
|
||||
],
|
||||
isStale,
|
||||
);
|
||||
} catch {
|
||||
// Local threw — fall through to explicit local retry below.
|
||||
}
|
||||
|
||||
if (winner && !isStale()) {
|
||||
logBrowseRaceOutcome(log, 'browse_race', winner, Math.round(performance.now() - t0));
|
||||
return winner;
|
||||
}
|
||||
|
||||
const { result: localResult, ms: localMs } = await timed(local);
|
||||
if (localResult != null && !isStale()) {
|
||||
const outcome: SearchRaceWinner<T> = {
|
||||
source: 'local',
|
||||
result: localResult,
|
||||
durationMs: localMs,
|
||||
};
|
||||
logBrowseRaceOutcome(
|
||||
log,
|
||||
'browse_local_fallback',
|
||||
outcome,
|
||||
Math.round(performance.now() - t0),
|
||||
'race_no_winner',
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const { result: networkResult, ms: networkMs } = await timed(() => safeNetwork(network));
|
||||
if (networkResult != null && !isStale()) {
|
||||
const outcome: SearchRaceWinner<T> = {
|
||||
source: 'network',
|
||||
result: networkResult,
|
||||
durationMs: networkMs,
|
||||
};
|
||||
logBrowseRaceOutcome(
|
||||
log,
|
||||
'browse_network_fallback',
|
||||
outcome,
|
||||
Math.round(performance.now() - t0),
|
||||
'local_unavailable',
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
logBrowseRaceOutcome(
|
||||
log,
|
||||
'browse_race_miss',
|
||||
null,
|
||||
Math.round(performance.now() - t0),
|
||||
'all_sources_empty',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function browseRaceCountsArtists(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: n, albums: 0, songs: 0 };
|
||||
}
|
||||
|
||||
export function browseRaceCountsSongs(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: 0, albums: 0, songs: n };
|
||||
}
|
||||
|
||||
export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const r = result as SearchResults;
|
||||
return {
|
||||
artists: r.artists?.length ?? 0,
|
||||
albums: r.albums?.length ?? 0,
|
||||
songs: r.songs?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const ARTIST_BROWSE_LIMIT = 500;
|
||||
|
||||
const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
resultType: 'artists',
|
||||
});
|
||||
|
||||
const songBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
resultType: 'songs',
|
||||
});
|
||||
|
||||
const fullSearchOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
resultType: 'all',
|
||||
});
|
||||
|
||||
/** Local artist name search for Artists / Composers browse pages. */
|
||||
export async function runLocalBrowseArtists(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
limit = ARTIST_BROWSE_LIMIT,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
emptyBrowseOpts(query),
|
||||
limit,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!page) return null;
|
||||
return page.artists;
|
||||
}
|
||||
|
||||
/** Network search3 artist slice for browse pages. */
|
||||
export async function runNetworkBrowseArtists(
|
||||
query: string,
|
||||
limit = ARTIST_BROWSE_LIMIT,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const r = await search(q, { artistCount: limit, albumCount: 0, songCount: 0 });
|
||||
return r.artists;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated local track text search (Tracks browse / VirtualSongList). */
|
||||
export async function runLocalBrowseSongPage(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
query: q,
|
||||
entityTypes: ['track'],
|
||||
limit: pageSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.tracks.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated network track text search. */
|
||||
export async function runNetworkBrowseSongPage(
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
return await searchSongsPaged(q, pageSize, offset);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full SearchResults page — local advanced search (all entity types). */
|
||||
export async function runLocalBrowseFullSearch(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
songsLimit: number,
|
||||
): Promise<SearchResults | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
fullSearchOpts(query),
|
||||
songsLimit,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!page) return null;
|
||||
return {
|
||||
artists: page.artists,
|
||||
albums: page.albums,
|
||||
songs: page.songs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Full SearchResults page — network search3. */
|
||||
export async function runNetworkBrowseFullSearch(
|
||||
query: string,
|
||||
songsLimit: number,
|
||||
): Promise<SearchResults | null> {
|
||||
try {
|
||||
const page = await runNetworkAdvancedTextSearch(fullSearchOpts(query), songsLimit);
|
||||
if (!page) return null;
|
||||
return {
|
||||
artists: page.artists,
|
||||
albums: page.albums,
|
||||
songs: page.songs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Next song page when the race winner was local (SearchResults / Tracks). */
|
||||
export async function loadMoreLocalBrowseSongs(
|
||||
serverId: string,
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[]> {
|
||||
return loadMoreLocalSongs(serverId, songBrowseOpts(query), offset, pageSize);
|
||||
}
|
||||
|
||||
export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] {
|
||||
if (sort === 'alphabeticalByArtist') {
|
||||
return [{ field: 'artist', dir: 'asc' }];
|
||||
}
|
||||
return [{ field: 'name', dir: 'asc' }];
|
||||
}
|
||||
|
||||
/** Paginated All Albums browse from the local `album` table (F1). */
|
||||
export async function runLocalAlbumBrowsePage(
|
||||
serverId: string | null | undefined,
|
||||
sort: AlbumBrowseSort,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
yearFilter?: { from: number; to: number },
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
const filters: LibraryFilterClause[] = [];
|
||||
if (yearFilter) {
|
||||
filters.push({
|
||||
field: 'year',
|
||||
op: 'between',
|
||||
value: yearFilter.from,
|
||||
valueTo: yearFilter.to,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['album'],
|
||||
filters,
|
||||
sort: yearFilter
|
||||
? [{ field: 'year', dir: 'desc' }, { field: 'name', dir: 'asc' }]
|
||||
: albumSortClauses(sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.albums.map(albumToAlbum);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const GENRE_ALBUM_FETCH_LIMIT = 500;
|
||||
|
||||
/** Genre-filtered album union for All Albums / Random Albums genre bar. */
|
||||
export async function runLocalAlbumsByGenres(
|
||||
serverId: string | null | undefined,
|
||||
genres: string[],
|
||||
sort: AlbumBrowseSort,
|
||||
limitPerGenre = GENRE_ALBUM_FETCH_LIMIT,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId)) || genres.length === 0) return null;
|
||||
try {
|
||||
const pages = await Promise.all(
|
||||
genres.map(genre =>
|
||||
libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['album'],
|
||||
filters: [{ field: 'genre', op: 'eq', value: genre }],
|
||||
sort: albumSortClauses(sort),
|
||||
limit: limitPerGenre,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (pages.some(p => p.source !== 'local')) return null;
|
||||
const merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
|
||||
return merged.sort((a, b) =>
|
||||
sort === 'alphabeticalByArtist'
|
||||
? a.artist.localeCompare(b.artist) || a.name.localeCompare(b.name)
|
||||
: a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Local artist table browse-all when the index is ready (optional fast path). */
|
||||
export async function runLocalBrowseAllArtists(
|
||||
serverId: string | null | undefined,
|
||||
limit = 10_000,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['artist'],
|
||||
limit,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.artists.map(artistToArtist);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeCapabilityFlags,
|
||||
explainLibraryReady,
|
||||
formatLibrarySearchLine,
|
||||
ingestStallHint,
|
||||
normalizeIngestMetrics,
|
||||
} from './libraryDevLog';
|
||||
@@ -85,4 +86,34 @@ describe('libraryDevLog', () => {
|
||||
}),
|
||||
).toBe('write_lock_held_by_other_op');
|
||||
});
|
||||
|
||||
it('formatLibrarySearchLine uses unified search prefix', () => {
|
||||
expect(
|
||||
formatLibrarySearchLine({
|
||||
at: '',
|
||||
query: 'foo',
|
||||
path: 'search_race',
|
||||
surface: 'live_search',
|
||||
durationMs: 14,
|
||||
raceWinner: 'local',
|
||||
raceWinnerMs: 9,
|
||||
counts: { artists: 1, albums: 2, songs: 3 },
|
||||
}),
|
||||
).toBe(
|
||||
'search [live_search] path=search_race winner=local raceMs=9 totalMs=14 hits=1/2/3',
|
||||
);
|
||||
expect(
|
||||
formatLibrarySearchLine({
|
||||
at: '',
|
||||
query: 'bar',
|
||||
path: 'search3',
|
||||
surface: 'advanced_search',
|
||||
source: 'network',
|
||||
durationMs: 120,
|
||||
invokeMs: 80,
|
||||
}),
|
||||
).toBe(
|
||||
'search [advanced_search] path=search3 source=network totalMs=120 invokeMs=80',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* DevTools diagnostics for local library index + Live Search (DEV only).
|
||||
/** DevTools diagnostics for local library index + search (DEV only).
|
||||
* Filter console: `[psysonic][library]`
|
||||
* Search one-liner: `search [surface] path=… winner=…` or `source=…`
|
||||
* Ring buffer: `window.__PSYSONIC_LIBRARY_DEBUG__`
|
||||
*/
|
||||
import type { SyncStateDto } from '../../api/library';
|
||||
@@ -14,9 +14,22 @@ export type LibrarySearchPath =
|
||||
| 'library_advanced_search'
|
||||
| 'search3'
|
||||
| 'search_race'
|
||||
| 'browse_race'
|
||||
| 'browse_local_fallback'
|
||||
| 'browse_network_fallback'
|
||||
| 'browse_race_miss'
|
||||
| 'skipped_not_ready'
|
||||
| 'local_empty_fallback';
|
||||
|
||||
/** UI surface for unified search DevTools lines (`search [surface] …`). */
|
||||
export type LibrarySearchSurface =
|
||||
| 'live_search'
|
||||
| 'advanced_search'
|
||||
| 'artists_browse'
|
||||
| 'composers_browse'
|
||||
| 'tracks_browse'
|
||||
| 'search_results';
|
||||
|
||||
export interface LibrarySearchDebugEntry {
|
||||
at: string;
|
||||
query: string;
|
||||
@@ -35,6 +48,9 @@ export interface LibrarySearchDebugEntry {
|
||||
/** Winner when local + network ran in parallel. */
|
||||
raceWinner?: 'local' | 'network';
|
||||
raceWinnerMs?: number;
|
||||
/** Direct (non-race) path source. */
|
||||
source?: 'local' | 'network';
|
||||
surface?: LibrarySearchSurface;
|
||||
}
|
||||
|
||||
export interface LibrarySyncDebugEntry {
|
||||
@@ -215,10 +231,39 @@ export function explainLibraryReady(status: SyncStateDto): string {
|
||||
return `syncPhase=${status.syncPhase}`;
|
||||
}
|
||||
|
||||
export function formatLibrarySearchLine(entry: LibrarySearchDebugEntry): string {
|
||||
const surface = entry.surface ?? '?';
|
||||
const hits = entry.counts
|
||||
? ` hits=${entry.counts.artists}/${entry.counts.albums}/${entry.counts.songs}`
|
||||
: '';
|
||||
const fallback = entry.fallbackReason ? ` fallback=${entry.fallbackReason}` : '';
|
||||
const invoke = entry.invokeMs != null ? ` invokeMs=${entry.invokeMs}` : '';
|
||||
const debounce = entry.debounceMs != null ? ` debounceMs=${entry.debounceMs}` : '';
|
||||
const error = entry.error ? ` error=${entry.error}` : '';
|
||||
|
||||
if (entry.raceWinner) {
|
||||
return (
|
||||
`search [${surface}] path=${entry.path} winner=${entry.raceWinner}` +
|
||||
` raceMs=${entry.raceWinnerMs ?? 0} totalMs=${entry.durationMs}` +
|
||||
`${invoke}${debounce}${hits}${fallback}${error}`
|
||||
);
|
||||
}
|
||||
if (entry.source) {
|
||||
return (
|
||||
`search [${surface}] path=${entry.path} source=${entry.source}` +
|
||||
` totalMs=${entry.durationMs}${invoke}${debounce}${hits}${fallback}${error}`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`search [${surface}] path=${entry.path} totalMs=${entry.durationMs}` +
|
||||
`${invoke}${debounce}${hits}${fallback}${error}`
|
||||
);
|
||||
}
|
||||
|
||||
export function logLibrarySearch(entry: LibrarySearchDebugEntry): void {
|
||||
if (!libraryDevEnabled()) return;
|
||||
pushRing('search', entry);
|
||||
console.debug(PREFIX, 'search', entry);
|
||||
console.debug(PREFIX, formatLibrarySearchLine(entry), entry);
|
||||
}
|
||||
|
||||
export function logLibrarySync(entry: LibrarySyncDebugEntry): void {
|
||||
|
||||
@@ -81,6 +81,8 @@ export async function runLocalLiveSearch(
|
||||
at: new Date().toISOString(),
|
||||
query: q,
|
||||
path: 'library_live_search',
|
||||
surface: 'live_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
invokeMs,
|
||||
counts: {
|
||||
@@ -98,6 +100,8 @@ export async function runLocalLiveSearch(
|
||||
at: new Date().toISOString(),
|
||||
query: q,
|
||||
path: 'library_live_search',
|
||||
surface: 'live_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
error: String(err),
|
||||
fallbackReason: 'invoke_failed',
|
||||
|
||||
Reference in New Issue
Block a user