refactor(search): extract advanced-search runner into a hook

This commit is contained in:
Psychotoxical
2026-07-01 00:13:31 +02:00
parent 87c3c588d9
commit 7f48071ec4
2 changed files with 449 additions and 364 deletions
@@ -0,0 +1,425 @@
import { useRef, useCallback, type Dispatch, type SetStateAction } from 'react';
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import {
loadMoreLocalSongs,
runNetworkAdvancedTextSearch,
runNetworkAdvancedYearAlbums,
tryRunLocalAdvancedSearch,
} from '@/lib/library/advancedSearchLocal';
import { isLosslessSuffix } from '@/lib/library/losslessFormats';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '@/lib/library/trackEnrichment';
import { raceSearchSources } from '@/lib/library/searchRace';
import { logLibrarySearch } from '@/lib/library/libraryDevLog';
import {
browseRaceCountsFullSearch,
loadMoreLocalBrowseSongs,
raceBrowseWithLocalFallback,
runLocalBrowseFullSearch,
runNetworkBrowseFullSearch,
} from '@/lib/library/browseTextSearch';
import type { SearchOpts, Results } from '@/features/search/searchBrowseTypes';
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
// Pagination — basic quick search uses smaller pages than advanced form search.
const BASIC_SONGS_INITIAL = 50;
const BASIC_SONGS_PAGE_SIZE = 50;
const SONGS_INITIAL = 100;
const SONGS_PAGE_SIZE = 50;
function applySongFilters(
list: SubsonicSong[],
g: string,
from: number | null,
to: number | null,
bpmLo: number | null,
bpmHi: number | null,
lossless = false,
): SubsonicSong[] {
let r = list;
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo);
if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi);
if (lossless) r = r.filter(s => isLosslessSuffix(s.suffix));
return r;
}
interface UseAdvancedSearchRunnerParams {
serverId: string | null;
indexEnabled: boolean;
loadingMoreSongs: boolean;
songsHasMore: boolean;
activeSearch: SearchOpts | null;
basicSearchMode: boolean;
localMode: boolean;
songsServerOffset: number;
setLoading: Dispatch<SetStateAction<boolean>>;
setHasSearched: Dispatch<SetStateAction<boolean>>;
setGenreNote: Dispatch<SetStateAction<boolean>>;
setBasicSearchMode: Dispatch<SetStateAction<boolean>>;
setQuery: Dispatch<SetStateAction<string>>;
setActiveSearch: Dispatch<SetStateAction<SearchOpts | null>>;
setSongsServerOffset: Dispatch<SetStateAction<number>>;
setSongsHasMore: Dispatch<SetStateAction<boolean>>;
setLocalMode: Dispatch<SetStateAction<boolean>>;
setResults: Dispatch<SetStateAction<Results | null>>;
setLoadingMoreSongs: Dispatch<SetStateAction<boolean>>;
}
/**
* The search-execution engine for the search shell: basic quick search, advanced form
* search (local-index/network race with filters + logging), and song pagination. Owns the
* run-id staleness guard; the shell owns the result/filter state passed in via setters.
*/
export function useAdvancedSearchRunner({
serverId,
indexEnabled,
loadingMoreSongs,
songsHasMore,
activeSearch,
basicSearchMode,
localMode,
songsServerOffset,
setLoading,
setHasSearched,
setGenreNote,
setBasicSearchMode,
setQuery,
setActiveSearch,
setSongsServerOffset,
setSongsHasMore,
setLocalMode,
setResults,
setLoadingMoreSongs,
}: UseAdvancedSearchRunnerParams) {
const searchRunRef = useRef(0);
const runBasicSearch = async (rawQuery: string) => {
const q = rawQuery.trim();
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
setLoading(true);
setHasSearched(true);
setGenreNote(false);
setBasicSearchMode(true);
setQuery(q);
setActiveSearch({
query: q,
genre: '',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
});
setSongsServerOffset(0);
setSongsHasMore(false);
setLocalMode(false);
if (!q) {
setResults(null);
setLoading(false);
return;
}
try {
if (serverId && indexEnabled) {
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseFullSearch(serverId, q, BASIC_SONGS_INITIAL),
() => runNetworkBrowseFullSearch(q, BASIC_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 >= BASIC_SONGS_INITIAL);
setLocalMode(outcome.source === 'local');
return;
}
}
const network = await runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL);
if (isStale()) return;
if (network) {
setResults(network);
setSongsServerOffset(network.songs.length);
setSongsHasMore(network.songs.length >= BASIC_SONGS_INITIAL);
} else {
setResults({ artists: [], albums: [], songs: [] });
}
} catch {
if (!isStale()) setResults(null);
} finally {
if (!isStale()) setLoading(false);
}
};
const runSearch = async (opts: SearchOpts) => {
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
setLoading(true);
setHasSearched(true);
setGenreNote(false);
setBasicSearchMode(false);
setActiveSearch(opts);
setSongsServerOffset(0);
setSongsHasMore(false);
const q = opts.query.trim();
const searchT0 = performance.now();
const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup;
const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo);
const losslessFilterActive = opts.losslessOnly;
const trackOnlyFilterActive = moodFilterActive || bpmFilterActive;
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
// Lossless skips the race — network search3 cannot filter albums by format reliably.
if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
try {
const winner = await raceSearchSources(
[
{
source: 'local',
run: () => tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, true),
},
{
source: 'network',
run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL),
},
],
isStale,
);
if (isStale()) return;
if (winner) {
setResults({
artists: winner.result.artists,
albums: winner.result.albums,
songs: winner.result.songs,
});
setSongsServerOffset(winner.result.songs.length);
setSongsHasMore(winner.result.songs.length >= SONGS_INITIAL);
setLocalMode(winner.source === 'local');
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
surface: 'advanced_search',
durationMs: Math.round(performance.now() - searchT0),
indexEnabled,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
setLoading(false);
return;
}
} catch {
if (isStale()) return;
}
setLocalMode(false);
} else if (serverId && indexEnabled) {
const localPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
if (isStale()) return;
if (localPage) {
setResults({
artists: localPage.artists,
albums: localPage.albums,
songs: localPage.songs,
});
setSongsServerOffset(localPage.songs.length);
setSongsHasMore(localPage.songs.length >= SONGS_INITIAL);
setLocalMode(true);
setLoading(false);
return;
}
if (trackOnlyFilterActive) {
setResults({ artists: [], albums: [], songs: [] });
setLoading(false);
return;
}
setLocalMode(false);
} else {
setLocalMode(false);
}
if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) {
setResults({ artists: [], albums: [], songs: [] });
setLoading(false);
return;
}
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, losslessOnly: lossless, resultType: rt } = opts;
const from = yf ? parseInt(yf) : null;
const to = yt ? parseInt(yt) : null;
const bpmLo = bf ? parseInt(bf) : null;
const bpmHi = bt ? parseInt(bt) : null;
let artists: SubsonicArtist[] = [];
let albums: SubsonicAlbum[] = [];
let songs: SubsonicSong[] = [];
try {
if (q.trim()) {
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
artists = r.artists;
albums = r.albums;
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless);
if (g) {
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
}
if (from !== null) {
albums = albums.filter(a => !a.year || a.year >= from);
}
if (to !== null) {
albums = albums.filter(a => !a.year || a.year <= to);
}
if (lossless) {
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
albums = albums.filter(a => albumIds.has(a.id));
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
artists = artists.filter(a => artistIds.has(a.id));
}
// Only the free-text branch supports server-side pagination via search3 offset.
// If the server returned a full page, more probably exist.
setSongsServerOffset(r.songs.length);
setSongsHasMore(r.songs.length === SONGS_INITIAL);
} else if (g) {
const [albumRes, songRes] = await Promise.all([
rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50),
rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g),
]);
albums = albumRes as SubsonicAlbum[];
songs = songRes as SubsonicSong[];
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless);
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
if (songs.length > 0) setGenreNote(true);
} else if (from !== null || to !== null) {
if (rt !== 'artists' && rt !== 'songs') {
albums = await runNetworkAdvancedYearAlbums(opts, 100);
}
}
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: [] });
}
setLoading(false);
};
const loadMoreSongs = useCallback(async () => {
if (loadingMoreSongs || !songsHasMore || !activeSearch) return;
if (basicSearchMode) {
const q = activeSearch.query.trim();
if (!q) return;
setLoadingMoreSongs(true);
try {
const page = localMode && serverId
? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, BASIC_SONGS_PAGE_SIZE)
: await searchSongsPaged(q, BASIC_SONGS_PAGE_SIZE, songsServerOffset);
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev);
setSongsServerOffset(o => o + page.length);
if (page.length < BASIC_SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
return;
}
// Local mode pages every result type (genre/year too), not just free-text.
if (localMode) {
if (!serverId) return;
setLoadingMoreSongs(true);
try {
const more = await loadMoreLocalSongs(serverId, activeSearch, songsServerOffset, SONGS_PAGE_SIZE);
setResults(prev => (prev ? { ...prev, songs: [...prev.songs, ...more] } : prev));
setSongsServerOffset(o => o + more.length);
if (more.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
return;
}
if (!activeSearch.query.trim()) return;
setLoadingMoreSongs(true);
try {
const q = activeSearch.query.trim();
const g = activeSearch.genre;
const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null;
const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null;
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
const filtered = applySongFilters(
page,
g,
from,
to,
bpmLo,
bpmHi,
activeSearch.losslessOnly,
);
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
setSongsServerOffset(o => o + page.length);
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
}, [
loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId, basicSearchMode,
setResults, setSongsServerOffset, setSongsHasMore, setLoadingMoreSongs,
]);
return { runBasicSearch, runSearch, loadMoreSongs };
}
+24 -364
View File
@@ -1,7 +1,5 @@
import { getGenres, getAlbumsByGenre } from '@/lib/api/subsonicGenres';
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import { getGenres } from '@/lib/api/subsonicGenres';
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
import type { ResultType, SearchOpts, Results } from '@/features/search/searchBrowseTypes';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useNavigationType, useSearchParams } from 'react-router-dom';
@@ -34,26 +32,11 @@ import {
type AdvancedSearchLeaveSnapshot,
} from '@/lib/navigation/advancedSearchScrollSnapshot';
import { restoreMainViewportScroll } from '@/lib/navigation/restoreMainViewportScroll';
import {
loadMoreLocalSongs,
runNetworkAdvancedTextSearch,
runNetworkAdvancedYearAlbums,
tryRunLocalAdvancedSearch,
} from '@/lib/library/advancedSearchLocal';
import { isLosslessSuffix } from '@/lib/library/losslessFormats';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '@/lib/library/trackEnrichment';
import { raceSearchSources } from '@/lib/library/searchRace';
import { logLibrarySearch } from '@/lib/library/libraryDevLog';
import {
browseRaceCountsFullSearch,
loadMoreLocalBrowseSongs,
raceBrowseWithLocalFallback,
runLocalBrowseFullSearch,
runNetworkBrowseFullSearch,
} from '@/lib/library/browseTextSearch';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { useSongBrowseList, type SongBrowseListRestore } from '@/features/search/hooks/useSongBrowseList';
import { useAdvancedSearchRunner } from '@/features/search/hooks/useAdvancedSearchRunner';
import TracksPageChrome from '@/features/search/components/TracksPageChrome';
import SongBrowseSection from '@/features/search/components/SongBrowseSection';
import {
@@ -122,13 +105,6 @@ export default function SearchBrowsePage() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const searchRunRef = useRef(0);
// Pagination — basic quick search uses smaller pages than advanced form search.
const BASIC_SONGS_INITIAL = 50;
const BASIC_SONGS_PAGE_SIZE = 50;
const SONGS_INITIAL = 100;
const SONGS_PAGE_SIZE = 50;
const [activeSearch, setActiveSearch] = useState<SearchOpts | null>(() => restoreStash?.activeSearch ?? null);
const [songsServerOffset, setSongsServerOffset] = useState(() => restoreStash?.songsServerOffset ?? 0);
const [songsHasMore, setSongsHasMore] = useState(() => restoreStash?.songsHasMore ?? false);
@@ -301,274 +277,27 @@ export default function SearchBrowsePage() {
return () => el.removeEventListener('scroll', syncScroll);
}, []);
const applySongFilters = (
list: SubsonicSong[],
g: string,
from: number | null,
to: number | null,
bpmLo: number | null,
bpmHi: number | null,
lossless = false,
): SubsonicSong[] => {
let r = list;
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo);
if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi);
if (lossless) r = r.filter(s => isLosslessSuffix(s.suffix));
return r;
};
const runBasicSearch = async (rawQuery: string) => {
const q = rawQuery.trim();
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
setLoading(true);
setHasSearched(true);
setGenreNote(false);
setBasicSearchMode(true);
setQuery(q);
setActiveSearch({
query: q,
genre: '',
yearFrom: '',
yearTo: '',
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all',
});
setSongsServerOffset(0);
setSongsHasMore(false);
setLocalMode(false);
if (!q) {
setResults(null);
setLoading(false);
return;
}
try {
if (serverId && indexEnabled) {
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseFullSearch(serverId, q, BASIC_SONGS_INITIAL),
() => runNetworkBrowseFullSearch(q, BASIC_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 >= BASIC_SONGS_INITIAL);
setLocalMode(outcome.source === 'local');
return;
}
}
const network = await runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL);
if (isStale()) return;
if (network) {
setResults(network);
setSongsServerOffset(network.songs.length);
setSongsHasMore(network.songs.length >= BASIC_SONGS_INITIAL);
} else {
setResults({ artists: [], albums: [], songs: [] });
}
} catch {
if (!isStale()) setResults(null);
} finally {
if (!isStale()) setLoading(false);
}
};
const runSearch = async (opts: SearchOpts) => {
const runId = ++searchRunRef.current;
const isStale = () => runId !== searchRunRef.current;
setLoading(true);
setHasSearched(true);
setGenreNote(false);
setBasicSearchMode(false);
setActiveSearch(opts);
setSongsServerOffset(0);
setSongsHasMore(false);
const q = opts.query.trim();
const searchT0 = performance.now();
const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup;
const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo);
const losslessFilterActive = opts.losslessOnly;
const trackOnlyFilterActive = moodFilterActive || bpmFilterActive;
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
// Lossless skips the race — network search3 cannot filter albums by format reliably.
if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
try {
const winner = await raceSearchSources(
[
{
source: 'local',
run: () => tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, true),
},
{
source: 'network',
run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL),
},
],
isStale,
);
if (isStale()) return;
if (winner) {
setResults({
artists: winner.result.artists,
albums: winner.result.albums,
songs: winner.result.songs,
});
setSongsServerOffset(winner.result.songs.length);
setSongsHasMore(winner.result.songs.length >= SONGS_INITIAL);
setLocalMode(winner.source === 'local');
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
surface: 'advanced_search',
durationMs: Math.round(performance.now() - searchT0),
indexEnabled,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
setLoading(false);
return;
}
} catch {
if (isStale()) return;
}
setLocalMode(false);
} else if (serverId && indexEnabled) {
const localPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
if (isStale()) return;
if (localPage) {
setResults({
artists: localPage.artists,
albums: localPage.albums,
songs: localPage.songs,
});
setSongsServerOffset(localPage.songs.length);
setSongsHasMore(localPage.songs.length >= SONGS_INITIAL);
setLocalMode(true);
setLoading(false);
return;
}
if (trackOnlyFilterActive) {
setResults({ artists: [], albums: [], songs: [] });
setLoading(false);
return;
}
setLocalMode(false);
} else {
setLocalMode(false);
}
if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) {
setResults({ artists: [], albums: [], songs: [] });
setLoading(false);
return;
}
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, losslessOnly: lossless, resultType: rt } = opts;
const from = yf ? parseInt(yf) : null;
const to = yt ? parseInt(yt) : null;
const bpmLo = bf ? parseInt(bf) : null;
const bpmHi = bt ? parseInt(bt) : null;
let artists: SubsonicArtist[] = [];
let albums: SubsonicAlbum[] = [];
let songs: SubsonicSong[] = [];
try {
if (q.trim()) {
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
artists = r.artists;
albums = r.albums;
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless);
if (g) {
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
}
if (from !== null) {
albums = albums.filter(a => !a.year || a.year >= from);
}
if (to !== null) {
albums = albums.filter(a => !a.year || a.year <= to);
}
if (lossless) {
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
albums = albums.filter(a => albumIds.has(a.id));
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
artists = artists.filter(a => artistIds.has(a.id));
}
// Only the free-text branch supports server-side pagination via search3 offset.
// If the server returned a full page, more probably exist.
setSongsServerOffset(r.songs.length);
setSongsHasMore(r.songs.length === SONGS_INITIAL);
} else if (g) {
const [albumRes, songRes] = await Promise.all([
rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50),
rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g),
]);
albums = albumRes as SubsonicAlbum[];
songs = songRes as SubsonicSong[];
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless);
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
if (songs.length > 0) setGenreNote(true);
} else if (from !== null || to !== null) {
if (rt !== 'artists' && rt !== 'songs') {
albums = await runNetworkAdvancedYearAlbums(opts, 100);
}
}
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: [] });
}
setLoading(false);
};
const { runBasicSearch, runSearch, loadMoreSongs } = useAdvancedSearchRunner({
serverId,
indexEnabled,
loadingMoreSongs,
songsHasMore,
activeSearch,
basicSearchMode,
localMode,
songsServerOffset,
setLoading,
setHasSearched,
setGenreNote,
setBasicSearchMode,
setQuery,
setActiveSearch,
setSongsServerOffset,
setSongsHasMore,
setLocalMode,
setResults,
setLoadingMoreSongs,
});
useEffect(() => {
return () => {
@@ -734,75 +463,6 @@ export default function SearchBrowsePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [musicLibraryFilterVersion, qFromUrl, showAdvancedPanel, showTracksChrome, serverId, indexEnabled]);
const loadMoreSongs = useCallback(async () => {
if (loadingMoreSongs || !songsHasMore || !activeSearch) return;
if (basicSearchMode) {
const q = activeSearch.query.trim();
if (!q) return;
setLoadingMoreSongs(true);
try {
const page = localMode && serverId
? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, BASIC_SONGS_PAGE_SIZE)
: await searchSongsPaged(q, BASIC_SONGS_PAGE_SIZE, songsServerOffset);
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev);
setSongsServerOffset(o => o + page.length);
if (page.length < BASIC_SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
return;
}
// Local mode pages every result type (genre/year too), not just free-text.
if (localMode) {
if (!serverId) return;
setLoadingMoreSongs(true);
try {
const more = await loadMoreLocalSongs(serverId, activeSearch, songsServerOffset, SONGS_PAGE_SIZE);
setResults(prev => (prev ? { ...prev, songs: [...prev.songs, ...more] } : prev));
setSongsServerOffset(o => o + more.length);
if (more.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
return;
}
if (!activeSearch.query.trim()) return;
setLoadingMoreSongs(true);
try {
const q = activeSearch.query.trim();
const g = activeSearch.genre;
const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null;
const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null;
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
const filtered = applySongFilters(
page,
g,
from,
to,
bpmLo,
bpmHi,
activeSearch.losslessOnly,
);
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
setSongsServerOffset(o => o + page.length);
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false);
} catch {
setSongsHasMore(false);
} finally {
setLoadingMoreSongs(false);
}
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId, basicSearchMode]);
const trackFilterActive =
(MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo);