Files
Psychotoxical-psysonic/src/pages/SearchResults.tsx
T
cucadmuh 7afddf7b84 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).
2026-05-22 01:44:25 +02:00

170 lines
6.1 KiB
TypeScript

import { searchSongsPaged } from '../api/subsonicSearch';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Search } from 'lucide-react';
import type { SearchResults as ISearchResults } from '../api/subsonicTypes';
import AlbumRow from '../components/AlbumRow';
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;
export default function SearchResults() {
const { t } = useTranslation();
const [params] = useSearchParams();
const query = params.get('q') ?? '';
const [results, setResults] = useState<ISearchResults | null>(null);
const [loading, setLoading] = useState(false);
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);
setLocalMode(false);
if (!q) {
setResults(null);
return;
}
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
setLoading(true);
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 () => {
const q = query.trim();
if (loadingMoreSongs || !songsHasMore || !q) return;
setLoadingMoreSongs(true);
try {
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);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
}, [loadingMoreSongs, songsHasMore, query, songsServerOffset, localMode, serverId]);
useEffect(() => {
const el = songsSentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) loadMoreSongs();
}, { rootMargin: '600px' });
obs.observe(el);
return () => obs.disconnect();
}, [loadMoreSongs]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div style={{ marginBottom: '-1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Search size={22} />
{query ? t('search.resultsFor', { query }) : t('search.title')}
</h1>
</div>
{loading && (
<div className="loading-center"><div className="spinner" /></div>
)}
{!loading && query && !hasResults && (
<div className="empty-state">{t('search.noResults', { query })}</div>
)}
{!loading && results && (
<>
{results.artists.length > 0 && (
<ArtistRow title={t('search.artists')} artists={results.artists} />
)}
{results.albums.length > 0 && (
<AlbumRow title={t('search.albums')} albums={results.albums} />
)}
{results.songs.length > 0 && (
<section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>{t('search.songs')}</h2>
<SongListHeader />
{results.songs.map(song => (
<SongRow key={song.id} song={song} />
))}
{songsHasMore && (
<div ref={songsSentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
{loadingMoreSongs && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</section>
)}
</>
)}
</div>
);
}