From 7afddf7b8477c31f283511d604a7237ada46cd67 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 22 May 2026 02:44:25 +0300 Subject: [PATCH] feat(library): browse local index race and catalog paths (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- CHANGELOG.md | 10 + src/components/LiveSearch.tsx | 3 + src/components/VirtualSongList.tsx | 112 ++++-- src/config/settingsCredits.ts | 1 + src/hooks/useBrowseArtistTextSearch.ts | 68 ++++ src/pages/AdvancedSearch.tsx | 22 +- src/pages/Albums.tsx | 51 ++- src/pages/Artists.tsx | 44 ++- src/pages/Composers.tsx | 22 +- src/pages/SearchResults.tsx | 82 +++- src/utils/library/advancedSearchLocal.ts | 4 + src/utils/library/browseTextSearch.test.ts | 45 +++ src/utils/library/browseTextSearch.ts | 427 +++++++++++++++++++++ src/utils/library/libraryDevLog.test.ts | 31 ++ src/utils/library/libraryDevLog.ts | 51 ++- src/utils/library/liveSearchLocal.ts | 4 + 16 files changed, 904 insertions(+), 73 deletions(-) create mode 100644 src/hooks/useBrowseArtistTextSearch.ts create mode 100644 src/utils/library/browseTextSearch.test.ts create mode 100644 src/utils/library/browseTextSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fe01a3..e9b472e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Library browse — local index race and catalog paths + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#847](https://github.com/Psychotoxical/psysonic/pull/847)** + +* **Artists**, **Composers**, **Tracks**, and **Search Results** text search races local FTS against network search3; a ready index still serves hits when remote is down. +* **All Albums** paginated browse and genre filter, plus **Artists** catalog browse-all, read from the local index when ready (network fallback unchanged). +* DevTools: `[psysonic][library] browse-race …` lines for race winner, timings, hit counts, and fallback reason. + + + ### Settings + Queue polish **By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), adopted from PR [#558](https://github.com/Psychotoxical/psysonic/pull/558), rewritten in PR [#778](https://github.com/Psychotoxical/psysonic/pull/778)** diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 1a5c7f04..b16a3b4b 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -192,6 +192,7 @@ export default function LiveSearch() { at: new Date().toISOString(), query: q, path: 'search_race', + surface: 'live_search', durationMs: Math.round(performance.now() - searchT0), debounceMs, indexEnabled, @@ -218,6 +219,8 @@ export default function LiveSearch() { at: new Date().toISOString(), query: q, path: 'search3', + surface: 'live_search', + source: 'network', durationMs: Math.round(performance.now() - searchT0), debounceMs, indexEnabled, diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx index 971d4b9b..13c7fbdd 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -8,29 +8,28 @@ import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ndListSongs } from '../api/navidromeBrowse'; import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal'; +import { + BROWSE_TEXT_DEBOUNCE_NETWORK_MS, + BROWSE_TEXT_DEBOUNCE_RACE_MS, + browseRaceCountsSongs, + loadMoreLocalBrowseSongs, + raceBrowseWithLocalFallback, + runLocalBrowseSongPage, + runNetworkBrowseSongPage, +} from '../utils/library/browseTextSearch'; import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import SongRow, { SongListHeader } from './SongRow'; const PAGE_SIZE = 50; -const SEARCH_DEBOUNCE_MS = 300; const ROW_HEIGHT = 52; const PREFETCH_PX = 600; -/** - * Browse-all (empty query): local library index when ready (F1, same title-ASC - * order), else Navidrome /api/song sorted by title, else Subsonic search3. - * Non-empty → Subsonic search3 (search isn't a browse). - * Either way, returns a SubsonicSong[]. - */ -async function fetchSongPage(query: string, offset: number): Promise { - if (query !== '') { - return searchSongsPaged(query, PAGE_SIZE, offset); - } - const local = await runLocalSongBrowse( - useAuthStore.getState().activeServerId, - offset, - PAGE_SIZE, - ); +async function fetchBrowseAllPage( + serverId: string | null | undefined, + offset: number, +): Promise { + const local = await runLocalSongBrowse(serverId, offset, PAGE_SIZE); if (local) return local; try { return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC'); @@ -46,6 +45,8 @@ interface Props { export default function VirtualSongList({ title, emptyBrowseText }: Props) { const { t } = useTranslation(); + const serverId = useAuthStore(s => s.activeServerId); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [songs, setSongs] = useState([]); @@ -58,29 +59,70 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { const scrollParentHeight = useRefElementClientHeight(scrollParentRef); const songListOverscan = Math.max(8, Math.ceil(scrollParentHeight / ROW_HEIGHT)); const requestSeqRef = useRef(0); + const localSearchModeRef = useRef(false); - // Debounce query useEffect(() => { - const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS); - return () => clearTimeout(h); - }, [query]); + const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS; + const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs); + return () => window.clearTimeout(timer); + }, [query, indexEnabled]); + + const fetchSongPage = useCallback( + async (q: string, pageOffset: number, isStale: () => boolean): Promise => { + if (q === '') { + return fetchBrowseAllPage(serverId, pageOffset); + } + + if (pageOffset === 0 && indexEnabled && serverId) { + const winner = await raceBrowseWithLocalFallback( + isStale, + () => runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE), + () => runNetworkBrowseSongPage(q, 0, PAGE_SIZE), + { + surface: 'tracks_browse', + query: q, + indexEnabled, + counts: browseRaceCountsSongs, + }, + ); + if (isStale()) return []; + if (winner) { + localSearchModeRef.current = winner.source === 'local'; + return winner.result ?? []; + } + localSearchModeRef.current = false; + return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE)) ?? []; + } + + if (localSearchModeRef.current && serverId) { + try { + return await loadMoreLocalBrowseSongs(serverId, q, pageOffset, PAGE_SIZE); + } catch { + return []; + } + } + + return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? []; + }, + [indexEnabled, serverId], + ); - // Reset + first-page fetch on query change. One effect, no dep cascade, - // and a `cancelled` flag so a fast typist doesn't see results from stale queries. useEffect(() => { let cancelled = false; setSongs([]); setOffset(0); setHasMore(true); setBrowseUnsupported(false); + localSearchModeRef.current = false; if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0; const seq = ++requestSeqRef.current; + const isStale = () => cancelled || seq !== requestSeqRef.current; setLoading(true); - (async () => { + void (async () => { try { - const page = await fetchSongPage(debouncedQuery, 0); - if (cancelled || seq !== requestSeqRef.current) return; + const page = await fetchSongPage(debouncedQuery, 0, isStale); + if (isStale()) return; if (page.length === 0) { setHasMore(false); if (debouncedQuery === '') setBrowseUnsupported(true); @@ -90,22 +132,25 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { if (page.length < PAGE_SIZE) setHasMore(false); } } catch { - if (!cancelled) setHasMore(false); + if (!isStale()) setHasMore(false); } finally { - if (!cancelled && seq === requestSeqRef.current) setLoading(false); + if (!isStale()) setLoading(false); } })(); - return () => { cancelled = true; }; - }, [debouncedQuery]); + return () => { + cancelled = true; + }; + }, [debouncedQuery, fetchSongPage]); const loadMore = useCallback(async () => { if (loading || !hasMore) return; setLoading(true); const seq = ++requestSeqRef.current; + const isStale = () => seq !== requestSeqRef.current; try { - const page = await fetchSongPage(debouncedQuery, offset); - if (seq !== requestSeqRef.current) return; + const page = await fetchSongPage(debouncedQuery, offset, isStale); + if (isStale()) return; if (page.length === 0) { setHasMore(false); } else { @@ -121,11 +166,10 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { } catch { setHasMore(false); } finally { - if (seq === requestSeqRef.current) setLoading(false); + if (!isStale()) setLoading(false); } - }, [loading, hasMore, debouncedQuery, offset]); + }, [loading, hasMore, debouncedQuery, offset, fetchSongPage]); - // Scroll-based prefetch — uses ref so a stale loadMore can't loop const loadMoreRef = useRef(loadMore); useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]); diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index c609d5f3..1669bb9b 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -123,6 +123,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Lucky Mix: hand off queue to browsed server after multi-server switch (PR #785)', 'Home album rails: stable play/enqueue hover on WebKitGTK/Wayland (PR #787)', 'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)', + 'Library browse: local-vs-network text search race, All Albums/Artists catalog from index, DevTools browse-race logging (PR #847)', ], }, { diff --git a/src/hooks/useBrowseArtistTextSearch.ts b/src/hooks/useBrowseArtistTextSearch.ts new file mode 100644 index 00000000..362ee1ef --- /dev/null +++ b/src/hooks/useBrowseArtistTextSearch.ts @@ -0,0 +1,68 @@ +import type { SubsonicArtist } from '../api/subsonicTypes'; +import { useEffect, useRef, useState } from 'react'; +import { + BROWSE_TEXT_DEBOUNCE_NETWORK_MS, + BROWSE_TEXT_DEBOUNCE_RACE_MS, + browseRaceCountsArtists, + raceBrowseWithLocalFallback, + runLocalBrowseArtists, + runNetworkBrowseArtists, + type LibrarySearchSurface, +} from '../utils/library/browseTextSearch'; + +/** + * Debounced artist/composer name search with local-vs-network race when the + * library index is enabled. Returns `textSearchArtists` when a raced query is + * active; callers should pass `effectiveFilter` (empty while raced) into their + * local filter hook so the query is not applied twice. + */ +export function useBrowseArtistTextSearch( + filter: string, + indexEnabled: boolean, + serverId: string | null | undefined, + surface: LibrarySearchSurface = 'artists_browse', +) { + const [debouncedFilter, setDebouncedFilter] = useState(''); + const [textSearchArtists, setTextSearchArtists] = useState(null); + const [textSearchLoading, setTextSearchLoading] = useState(false); + const searchGenRef = useRef(0); + + useEffect(() => { + const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS; + const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms); + return () => window.clearTimeout(timer); + }, [filter, indexEnabled]); + + useEffect(() => { + const q = debouncedFilter; + if (!q || !indexEnabled || !serverId) { + setTextSearchArtists(null); + setTextSearchLoading(false); + return; + } + + const gen = ++searchGenRef.current; + const isStale = () => gen !== searchGenRef.current; + setTextSearchLoading(true); + + void (async () => { + const outcome = await raceBrowseWithLocalFallback( + isStale, + () => runLocalBrowseArtists(serverId, q), + () => runNetworkBrowseArtists(q), + { + surface, + query: q, + indexEnabled, + counts: browseRaceCountsArtists, + }, + ); + if (isStale()) return; + setTextSearchArtists(outcome?.result ?? null); + setTextSearchLoading(false); + })(); + }, [debouncedFilter, indexEnabled, serverId, surface]); + + const effectiveFilter = textSearchArtists != null ? '' : filter; + return { textSearchArtists, textSearchLoading, effectiveFilter }; +} diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 5be811ee..7fbc7462 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -138,6 +138,7 @@ export default function AdvancedSearch() { at: new Date().toISOString(), query: q, path: 'search_race', + surface: 'advanced_search', durationMs: Math.round(performance.now() - searchT0), indexEnabled, raceWinner: winner.source, @@ -218,11 +219,28 @@ export default function AdvancedSearch() { albums = await getAlbumList('byYear', 100, 0, { fromYear, toYear }); } - setResults({ + const finalResults = { artists: rt === 'albums' || rt === 'songs' ? [] : artists, albums: rt === 'artists' || rt === 'songs' ? [] : albums, songs: rt === 'artists' || rt === 'albums' ? [] : songs, - }); + }; + setResults(finalResults); + if (q.trim()) { + logLibrarySearch({ + at: new Date().toISOString(), + query: q, + path: 'search3', + surface: 'advanced_search', + source: 'network', + durationMs: Math.round(performance.now() - searchT0), + indexEnabled, + counts: { + artists: finalResults.artists.length, + albums: finalResults.albums.length, + songs: finalResults.songs.length, + }, + }); + } } catch { setResults({ artists: [], albums: [], songs: [] }); } diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index df7edcf6..544cb182 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -26,8 +26,14 @@ import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeader import { VirtualCardGrid } from '../components/VirtualCardGrid'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { + runLocalAlbumBrowsePage, + runLocalAlbumsByGenres, + type AlbumBrowseSort, +} from '../utils/library/browseTextSearch'; -type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; +type SortType = AlbumBrowseSort; type CompFilter = 'all' | 'only' | 'hide'; const PAGE_SIZE = 30; @@ -47,6 +53,7 @@ export default function Albums() { const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); @@ -183,32 +190,50 @@ export default function Albums() { ) => { setLoading(true); try { - const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {}; - const type = yearFilter ? 'byYear' : sortType; - const data = await getAlbumList(type, PAGE_SIZE, offset, extra); + let data: SubsonicAlbum[] | null = null; + if (indexEnabled && serverId) { + data = await runLocalAlbumBrowsePage( + serverId, + sortType, + offset, + PAGE_SIZE, + yearFilter, + ); + } + if (data == null) { + const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {}; + const type = yearFilter ? 'byYear' : sortType; + data = await getAlbumList(type, PAGE_SIZE, offset, extra); + } if (append) setAlbums(prev => [...prev, ...data]); else setAlbums(data); setHasMore(data.length === PAGE_SIZE); } finally { setLoading(false); } - }, [musicLibraryFilterVersion]); + }, [musicLibraryFilterVersion, indexEnabled, serverId]); const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => { setLoading(true); try { - const data = await fetchByGenres(genres); - const sorted = [...data].sort((a, b) => - sortType === 'alphabeticalByArtist' - ? a.artist.localeCompare(b.artist) - : a.name.localeCompare(b.name) - ); - setAlbums(sorted); + let data: SubsonicAlbum[] | null = null; + if (indexEnabled && serverId) { + data = await runLocalAlbumsByGenres(serverId, genres, sortType); + } + if (data == null) { + data = await fetchByGenres(genres); + data = [...data].sort((a, b) => + sortType === 'alphabeticalByArtist' + ? a.artist.localeCompare(b.artist) + : a.name.localeCompare(b.name), + ); + } + setAlbums(data); setHasMore(false); } finally { setLoading(false); } - }, [musicLibraryFilterVersion]); + }, [musicLibraryFilterVersion, indexEnabled, serverId]); useEffect(() => { setPage(0); diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 8e0f52ec..77bbca00 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -23,15 +23,18 @@ import { ARTIST_LIST_ROW_EST, } from '../utils/componentHelpers/artistsHelpers'; import { useArtistsFiltering } from '../hooks/useArtistsFiltering'; +import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { runLocalBrowseAllArtists } from '../utils/library/browseTextSearch'; import { ArtistsGridView } from '../components/artists/ArtistsGridView'; import { ArtistsListView } from '../components/artists/ArtistsListView'; export default function Artists() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); - const [artists, setArtists] = useState([]); + const [catalogArtists, setCatalogArtists] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL); @@ -61,6 +64,14 @@ export default function Artists() { const openContextMenu = usePlayerStore(state => state.openContextMenu); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const serverId = useAuthStore(s => s.activeServerId); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( + filter, + indexEnabled, + serverId, + ); + const artists = textSearchArtists ?? catalogArtists; // ── Multi-selection ────────────────────────────────────────────────────── const [selectionMode, setSelectionMode] = useState(false); @@ -82,12 +93,34 @@ export default function Artists() { const selectedArtists = artists.filter(a => selectedIds.has(a.id)); useEffect(() => { - getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false)); - }, [musicLibraryFilterVersion]); + let cancelled = false; + setLoading(true); + void (async () => { + if (indexEnabled && serverId) { + const local = await runLocalBrowseAllArtists(serverId); + if (!cancelled && local != null) { + setCatalogArtists(local); + setLoading(false); + return; + } + } + try { + const data = await getArtists(); + if (!cancelled) setCatalogArtists(data); + } catch { + /* ignore */ + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [musicLibraryFilterVersion, indexEnabled, serverId]); const { filtered, visible, hasMore, groups, letters, artistListFlatRows, - } = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode }); + } = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode }); const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [ filter, @@ -207,6 +240,9 @@ export default function Artists() { onChange={e => setFilter(e.target.value)} id="artist-filter-input" /> + {textSearchLoading && ( +
+ )}
diff --git a/src/pages/Composers.tsx b/src/pages/Composers.tsx index 17fb76c8..145cf4e7 100644 --- a/src/pages/Composers.tsx +++ b/src/pages/Composers.tsx @@ -11,6 +11,8 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { APP_MAIN_SCROLL_VIEWPORT_ID, COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; +import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; import OverlayScrollArea from '../components/OverlayScrollArea'; @@ -90,6 +92,15 @@ export default function Composers() { const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const serverId = useAuthStore(s => s.activeServerId); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( + filter, + indexEnabled, + serverId, + 'composers_browse', + ); + const composerSource = textSearchArtists ?? composers; useEffect(() => { let cancelled = false; @@ -132,7 +143,7 @@ export default function Composers() { const starredOverrides = usePlayerStore(s => s.starredOverrides); const filtered = useMemo(() => { - let out = composers; + let out = composerSource; if (letterFilter !== ALL_SENTINEL) { out = out.filter(a => { const first = a.name[0]?.toUpperCase() ?? '#'; @@ -141,15 +152,15 @@ export default function Composers() { return first === letterFilter; }); } - if (filter) { - const needle = filter.toLowerCase(); + if (effectiveFilter) { + const needle = effectiveFilter.toLowerCase(); out = out.filter(a => a.name.toLowerCase().includes(needle)); } if (starredOnly) { out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred); } return out; - }, [composers, letterFilter, filter, starredOnly, starredOverrides]); + }, [composerSource, letterFilter, effectiveFilter, starredOnly, starredOverrides]); const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]); const hasMore = visibleCount < filtered.length; @@ -284,6 +295,9 @@ export default function Composers() { onChange={e => setFilter(e.target.value)} id="composer-filter-input" /> + {textSearchLoading && ( +
+ )}
diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index d501f0c5..06bf5bdb 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,4 +1,4 @@ -import { search, searchSongsPaged } from '../api/subsonicSearch'; +import { searchSongsPaged } from '../api/subsonicSearch'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Search } from 'lucide-react'; @@ -8,6 +8,14 @@ import ArtistRow from '../components/ArtistRow'; import SongRow, { SongListHeader } from '../components/SongRow'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { + browseRaceCountsFullSearch, + loadMoreLocalBrowseSongs, + raceBrowseWithLocalFallback, + runLocalBrowseFullSearch, + runNetworkBrowseFullSearch, +} from '../utils/library/browseTextSearch'; const SONGS_INITIAL = 50; const SONGS_PAGE_SIZE = 50; @@ -21,28 +29,76 @@ export default function SearchResults() { const [songsServerOffset, setSongsServerOffset] = useState(0); const [songsHasMore, setSongsHasMore] = useState(false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const [localMode, setLocalMode] = useState(false); const songsSentinelRef = useRef(null); + const searchRunRef = useRef(0); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const serverId = useAuthStore(s => s.activeServerId); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); useEffect(() => { + const q = query.trim(); setSongsServerOffset(0); setSongsHasMore(false); - if (!query.trim()) { setResults(null); return; } + setLocalMode(false); + if (!q) { + setResults(null); + return; + } + + const runId = ++searchRunRef.current; + const isStale = () => runId !== searchRunRef.current; setLoading(true); - search(query, { artistCount: 20, albumCount: 20, songCount: SONGS_INITIAL }) - .then(r => { - setResults(r); - setSongsServerOffset(r.songs.length); - setSongsHasMore(r.songs.length === SONGS_INITIAL); - }) - .finally(() => setLoading(false)); - }, [query, musicLibraryFilterVersion]); + + void (async () => { + try { + if (serverId && indexEnabled) { + const outcome = await raceBrowseWithLocalFallback( + isStale, + () => runLocalBrowseFullSearch(serverId, q, SONGS_INITIAL), + () => runNetworkBrowseFullSearch(q, SONGS_INITIAL), + { + surface: 'search_results', + query: q, + indexEnabled, + counts: browseRaceCountsFullSearch, + }, + ); + if (isStale()) return; + if (outcome) { + setResults(outcome.result); + setSongsServerOffset(outcome.result.songs.length); + setSongsHasMore(outcome.result.songs.length >= SONGS_INITIAL); + setLocalMode(outcome.source === 'local'); + return; + } + } + + const network = await runNetworkBrowseFullSearch(q, SONGS_INITIAL); + if (isStale()) return; + if (network) { + setResults(network); + setSongsServerOffset(network.songs.length); + setSongsHasMore(network.songs.length >= SONGS_INITIAL); + } else { + setResults({ artists: [], albums: [], songs: [] }); + } + } catch { + if (!isStale()) setResults(null); + } finally { + if (!isStale()) setLoading(false); + } + })(); + }, [query, musicLibraryFilterVersion, serverId, indexEnabled]); const loadMoreSongs = useCallback(async () => { - if (loadingMoreSongs || !songsHasMore || !query.trim()) return; + const q = query.trim(); + if (loadingMoreSongs || !songsHasMore || !q) return; setLoadingMoreSongs(true); try { - const page = await searchSongsPaged(query.trim(), SONGS_PAGE_SIZE, songsServerOffset); + const page = localMode && serverId + ? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, SONGS_PAGE_SIZE) + : await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset); setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev); setSongsServerOffset(o => o + page.length); if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); @@ -51,7 +107,7 @@ export default function SearchResults() { } finally { setLoadingMoreSongs(false); } - }, [loadingMoreSongs, songsHasMore, query, songsServerOffset]); + }, [loadingMoreSongs, songsHasMore, query, songsServerOffset, localMode, serverId]); useEffect(() => { const el = songsSentinelRef.current; diff --git a/src/utils/library/advancedSearchLocal.ts b/src/utils/library/advancedSearchLocal.ts index 4c2b077c..96f86dba 100644 --- a/src/utils/library/advancedSearchLocal.ts +++ b/src/utils/library/advancedSearchLocal.ts @@ -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), }); diff --git a/src/utils/library/browseTextSearch.test.ts b/src/utils/library/browseTextSearch.test.ts new file mode 100644 index 00000000..26cdbe52 --- /dev/null +++ b/src/utils/library/browseTextSearch.test.ts @@ -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']); + }); +}); diff --git a/src/utils/library/browseTextSearch.ts b/src/utils/library/browseTextSearch.ts new file mode 100644 index 00000000..1821e7de --- /dev/null +++ b/src/utils/library/browseTextSearch.ts @@ -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 | 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(run: () => Promise): Promise { + 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( + isStale: () => boolean, + local: () => Promise, + network: () => Promise, + log?: BrowseRaceLogOptions, +): Promise | null> { + if (isStale()) return null; + + const t0 = performance.now(); + let winner: SearchRaceWinner | 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 = { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/utils/library/libraryDevLog.test.ts b/src/utils/library/libraryDevLog.test.ts index 9eda6f67..8925887b 100644 --- a/src/utils/library/libraryDevLog.test.ts +++ b/src/utils/library/libraryDevLog.test.ts @@ -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', + ); + }); }); diff --git a/src/utils/library/libraryDevLog.ts b/src/utils/library/libraryDevLog.ts index 717d0970..9a8f9107 100644 --- a/src/utils/library/libraryDevLog.ts +++ b/src/utils/library/libraryDevLog.ts @@ -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 { diff --git a/src/utils/library/liveSearchLocal.ts b/src/utils/library/liveSearchLocal.ts index 864b0440..609e7c01 100644 --- a/src/utils/library/liveSearchLocal.ts +++ b/src/utils/library/liveSearchLocal.ts @@ -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',