mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(library): browse local index race and catalog paths (#847)
* feat(library): race local index vs network on browse text search Wire Artists, Composers, Tracks, and SearchResults to parallel local FTS and network search3 with graceful fallback when remote fails while the index is ready. * feat(library): local browse for albums/artists and dev race logging Serve All Albums and Artists catalog from the local index when ready, with network fallback. Log browse text-search race outcomes to DevTools (`[psysonic][library] browse-race …`) including winner, timings, and hits. * docs(changelog): note PR #847 browse local index race and catalog paths * refactor(library): unify DevTools search log format Live Search, Advanced Search, and browse races emit one-line `search [surface] …` entries via formatLibrarySearchLine (DEV only).
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user