From 7f48071ec40e00e9af47dd2c04c73a8c951c4ed2 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:13:31 +0200 Subject: [PATCH] refactor(search): extract advanced-search runner into a hook --- .../search/hooks/useAdvancedSearchRunner.ts | 425 ++++++++++++++++++ .../search/pages/SearchBrowsePage.tsx | 388 +--------------- 2 files changed, 449 insertions(+), 364 deletions(-) create mode 100644 src/features/search/hooks/useAdvancedSearchRunner.ts diff --git a/src/features/search/hooks/useAdvancedSearchRunner.ts b/src/features/search/hooks/useAdvancedSearchRunner.ts new file mode 100644 index 00000000..d4dc171b --- /dev/null +++ b/src/features/search/hooks/useAdvancedSearchRunner.ts @@ -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>; + setHasSearched: Dispatch>; + setGenreNote: Dispatch>; + setBasicSearchMode: Dispatch>; + setQuery: Dispatch>; + setActiveSearch: Dispatch>; + setSongsServerOffset: Dispatch>; + setSongsHasMore: Dispatch>; + setLocalMode: Dispatch>; + setResults: Dispatch>; + setLoadingMoreSongs: Dispatch>; +} + +/** + * 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 }; +} diff --git a/src/features/search/pages/SearchBrowsePage.tsx b/src/features/search/pages/SearchBrowsePage.tsx index 6f13e8fc..7540e4cd 100644 --- a/src/features/search/pages/SearchBrowsePage.tsx +++ b/src/features/search/pages/SearchBrowsePage.tsx @@ -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(() => 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);