import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SlidersVertical } from 'lucide-react'; import { search, searchSongsPaged, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import SongRow, { SongListHeader } from '../components/SongRow'; import CustomSelect from '../components/CustomSelect'; import { useAuthStore } from '../store/authStore'; type ResultType = 'all' | 'artists' | 'albums' | 'songs'; interface SearchOpts { query: string; genre: string; yearFrom: string; yearTo: string; resultType: ResultType; } interface Results { artists: SubsonicArtist[]; albums: SubsonicAlbum[]; songs: SubsonicSong[]; } export default function AdvancedSearch() { const { t } = useTranslation(); const [params] = useSearchParams(); const qFromUrl = params.get('q') ?? ''; const [query, setQuery] = useState(params.get('q') ?? ''); const [genre, setGenre] = useState(''); const [yearFrom, setYearFrom] = useState(''); const [yearTo, setYearTo] = useState(''); const [resultType, setResultType] = useState('all'); const [genres, setGenres] = useState([]); const [results, setResults] = useState(null); const total = results ? results.artists.length + results.albums.length + results.songs.length : 0; const [loading, setLoading] = useState(false); const [hasSearched, setHasSearched] = useState(false); const [genreNote, setGenreNote] = useState(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); // Pagination — only the free-text-query branch uses search3 with offset const SONGS_INITIAL = 100; const SONGS_PAGE_SIZE = 50; const [activeSearch, setActiveSearch] = useState(null); const [songsServerOffset, setSongsServerOffset] = useState(0); const [songsHasMore, setSongsHasMore] = useState(false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); const songsSentinelRef = useRef(null); const applySongFilters = ( list: SubsonicSong[], g: string, from: number | null, to: number | null, ): 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); return r; }; const runSearch = async (opts: SearchOpts) => { setLoading(true); setHasSearched(true); setGenreNote(false); setActiveSearch(opts); setSongsServerOffset(0); setSongsHasMore(false); const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts; const from = yf ? parseInt(yf) : null; const to = yt ? parseInt(yt) : 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); 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); } // 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[]; 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) { const fromYear = from ?? 1900; const toYear = to ?? new Date().getFullYear(); albums = await getAlbumList('byYear', 100, 0, { fromYear, toYear }); } setResults({ artists: rt === 'albums' || rt === 'songs' ? [] : artists, albums: rt === 'artists' || rt === 'songs' ? [] : albums, songs: rt === 'artists' || rt === 'albums' ? [] : songs, }); } catch { setResults({ artists: [], albums: [], songs: [] }); } setLoading(false); }; useEffect(() => { getGenres().then(data => setGenres(data.sort((a, b) => a.value.localeCompare(b.value))) ).catch(() => {}); if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' }); }, [musicLibraryFilterVersion, qFromUrl]); const loadMoreSongs = useCallback(async () => { if (loadingMoreSongs || !songsHasMore) return; if (!activeSearch || !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 page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset); const filtered = applySongFilters(page, g, from, to); 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]); // IntersectionObserver on the bottom sentinel — fires loadMoreSongs as it nears the viewport. 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 handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); runSearch({ query, genre, yearFrom, yearTo, resultType }); }; const typeOptions: { id: ResultType; label: string }[] = [ { id: 'all', label: t('search.advancedAll') }, { id: 'artists', label: t('search.artists') }, { id: 'albums', label: t('search.albums') }, { id: 'songs', label: t('search.songs') }, ]; const genreSelectOptions = [ { value: '', label: t('search.advancedAllGenres') }, ...genres.map(g => ({ value: g.value, label: g.value })), ]; return (

{t('search.advanced')}

{/* ── Filter panel ──────────────────────────────────────── */}
{/* Row 1: Search term */}
{t('search.advancedSearchTerm')} setQuery(e.target.value)} placeholder={t('search.advancedSearchPlaceholder')} style={{ flex: 1 }} autoFocus />
{/* Row 2: Genre + Year */}
{t('search.advancedGenre')}
{t('search.advancedYear')} setYearFrom(e.target.value)} placeholder={t('search.advancedYearFrom')} style={{ width: 96 }} /> setYearTo(e.target.value)} placeholder={t('search.advancedYearTo')} style={{ width: 96 }} />
{/* Row 3: Result type + Search button */}
{typeOptions.map(opt => ( ))}
{/* ── Results ───────────────────────────────────────────── */} {!hasSearched ? (
{t('search.advancedEmpty')}
) : loading ? (
) : total === 0 ? (
{t('search.advancedNoResults')}
) : (
{results && results.artists.length > 0 && ( )} {results && results.albums.length > 0 && ( )} {results && results.songs.length > 0 && (

{t('search.songs')} {genreNote && ( — {t('search.advancedGenreNote')} )}

{results.songs.map(song => ( ))} {songsHasMore && (
{loadingMoreSongs &&
}
)}
)}
)}
); }