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:
cucadmuh
2026-05-22 02:44:25 +03:00
committed by GitHub
parent bd742c958c
commit 7afddf7b84
16 changed files with 904 additions and 73 deletions
+10
View File
@@ -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)**
+3
View File
@@ -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,
+78 -34
View File
@@ -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<SubsonicSong[]> {
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<SubsonicSong[]> {
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<SubsonicSong[]>([]);
@@ -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<SubsonicSong[]> => {
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]);
+1
View File
@@ -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)',
],
},
{
+68
View File
@@ -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<SubsonicArtist[] | null>(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 };
}
+20 -2
View File
@@ -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: [] });
}
+38 -13
View File
@@ -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);
+40 -4
View File
@@ -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<SubsonicArtist[]>([]);
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
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 && (
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
+18 -4
View File
@@ -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 && (
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
+69 -13
View File
@@ -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<HTMLDivElement>(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;
+4
View File
@@ -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']);
});
});
+427
View File
@@ -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;
}
}
+31
View File
@@ -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',
);
});
});
+48 -3
View File
@@ -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 {
+4
View File
@@ -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',