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',