import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { toast } from 'sonner'; import { ArrowLeft, ChevronDown, ChevronUp, Download, Lock, Loader2, Folder, Zap, Users, Search, X, RefreshCw, } from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards'; import { UserMenu } from './UserMenu'; import { SLSKD_REFRESH_CHANNEL, basename, formatSize, formatSpeed, formatDuration, groupResponses, type ResultUser, type ResultFolder, type ResultFile, type SlskdResponse, type SlskdSearchSummary, } from './shared'; // The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses), // never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files, // each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus // per-folder download actions. // // Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of // reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest // of its folders. Download buttons always queue exactly what is on screen. // // Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in // the background. Never serve the cache alone — opening a search before its first responses land used to // freeze that empty snapshot forever. While the search is still running we also poll, so results fill in // live; the header's refresh re-reads on demand. const MAX_USERS = 200; const POLL_MS = 2500; // Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling). const MAX_POLLS = 48; const resultsCache = new Map(); export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId); const fileKey = (username: string, filename: string) => `${username}::${filename}`; // Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query). const LOSSLESS = new Set(['flac', 'wav', 'aiff', 'aif', 'ape', 'alac', 'wv']); const EXT_PILLS = ['flac', 'mp3', 'wav', 'm4a', 'ogg']; const BITRATE_PILLS = [256, 320]; type Filters = { text: string; freeOnly: boolean; hideLocked: boolean; exts: Set; minBitrate: number | null; }; const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, exts: new Set(), minBitrate: null }; const filtersActive = (f: Filters) => f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null; // Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track // name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to // the whole folder alongside its matching files, and can be expanded back to the full album in place — // otherwise finding it would be the same act as hiding the rest of it. type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number }; type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number }; const folderKey = (username: string, path: string) => `${username}::${path}`; // Same reveal set, for "this peer's folders that matched nothing" — the discography around the album. const userKey = (username: string) => `user::${username}`; const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => { const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean); const pos = terms.filter((t) => !t.startsWith('-')); const neg = terms .filter((t) => t.startsWith('-')) .map((t) => t.slice(1)) .filter(Boolean); const fileOk = (file: ResultFile) => { if (f.hideLocked && file.isLocked) return false; if (f.exts.size && !f.exts.has(file.extension)) return false; if (f.minBitrate !== null && !((file.bitRate ?? 0) >= f.minBitrate || LOSSLESS.has(file.extension))) return false; const hay = file.filename.toLowerCase(); if (pos.some((t) => !hay.includes(t))) return false; if (neg.some((t) => hay.includes(t))) return false; return true; }; const out: UserMatch[] = []; for (const user of users) { if (f.freeOnly && !user.hasFreeUploadSlot) continue; const folders: FolderMatch[] = []; for (const folder of user.folders) { const matched = folder.files.filter(fileOk); if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) }); } if (folders.length) { out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) }); } } return out; }; // `initial` is the history row we came from, when there is one. Opening by URL — or landing here the // moment a search is created — has no row yet, so the summary is fetched like everything else and the // header fills in a beat later. type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void }; export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => { const client = useClient(); const [, bumpRefresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); const cachedInit = resultsCache.get(searchId) ?? null; const [users, setUsers] = useState(cachedInit); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [summary, setSummary] = useState(initial ?? null); const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false); // Users start expanded; toggling collapses them. const [open, setOpen] = useState>(() => new Set(cachedInit?.map((u) => u.username))); const [queued, setQueued] = useState>(new Set()); const [filters, setFilters] = useState(EMPTY_FILTERS); // Folders the reader has expanded past the filter, keyed username::path. const [revealed, setRevealed] = useState>(new Set()); // Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening // the cards the reader deliberately collapsed. const seenRef = useRef>(new Set(cachedInit?.map((u) => u.username) ?? [])); const aliveRef = useRef(true); const pollsRef = useRef(0); const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]); useEffect(() => { aliveRef.current = true; return () => { aliveRef.current = false; }; }, []); const refresh = useCallback( async (quiet: boolean) => { if (!quiet) setLoading(true); try { // The summary tells us whether to keep polling; tolerate its absence rather than fail the load. const [responses, meta] = await Promise.all([ client.get(`/soulseek/api/v0/searches/${searchId}/responses`), client.get(`/soulseek/api/v0/searches/${searchId}`).catch(() => null), ]); if (!aliveRef.current) return; const grouped = groupResponses(responses); resultsCache.set(searchId, grouped); setUsers(grouped); setError(null); setOpen((prev) => { const next = new Set(prev); for (const u of grouped) { if (!seenRef.current.has(u.username)) { seenRef.current.add(u.username); next.add(u.username); } } return next; }); if (meta) { setSummary(meta); setIsComplete(meta.isComplete); } } catch (err) { if (aliveRef.current) setError(err instanceof Error ? err.message : String(err)); } finally { if (aliveRef.current) setLoading(false); } }, // useClient() returns a fresh object each render, so depend only on the search id. // eslint-disable-next-line react-hooks/exhaustive-deps [searchId], ); useEffect(() => { const cached = resultsCache.get(searchId) ?? null; seenRef.current = new Set(cached?.map((u) => u.username) ?? []); pollsRef.current = 0; setUsers(cached); setOpen(new Set(cached?.map((u) => u.username))); setRevealed(new Set()); setError(null); // Assume running until the summary says otherwise — a search opened straight after being created has // no row to tell us, and guessing "complete" would mean never polling for its first responses. setIsComplete(initial?.id === searchId ? initial.isComplete : false); // Quiet when something is already on screen — no spinner flash over usable results. refresh(Boolean(cached)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchId, refresh]); useEffect(() => { if (isComplete) return; const timer = setInterval(() => { pollsRef.current += 1; if (pollsRef.current > MAX_POLLS) { clearInterval(timer); return; } refresh(true); }, POLL_MS); return () => clearInterval(timer); }, [isComplete, refresh]); const toggle = (username: string) => setOpen((prev) => { const next = new Set(prev); if (next.has(username)) next.delete(username); else next.add(username); return next; }); const toggleReveal = (key: string) => setRevealed((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const queue = async (username: string, files: ResultFile[]) => { const usable = files.filter((f) => !f.isLocked); if (usable.length === 0) return; try { await client.post( `/soulseek/api/v0/transfers/downloads/${encodeURIComponent(username)}`, usable.map((f) => ({ filename: f.filename, size: f.size })), ); setQueued((prev) => { const next = new Set(prev); usable.forEach((f) => next.add(fileKey(username, f.filename))); return next; }); bumpRefresh(Date.now()); toast.success(usable.length === 1 ? `Queued ${basename(usable[0]!.filename)}` : `Queued ${usable.length} files`); } catch (err) { toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`); } }; const toggleExt = (ext: string) => setFilters((f) => { const exts = new Set(f.exts); if (exts.has(ext)) exts.delete(ext); else exts.add(ext); return { ...f, exts }; }); const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n })); const shown = filtered ? filtered.slice(0, MAX_USERS) : []; const totalFiles = filtered?.reduce((n, u) => n + u.matchedCount, 0) ?? 0; const anyFilter = filtersActive(filters); return (
{summary?.searchText ?? initial?.searchText ?? '…'} {!isComplete && ( searching )}
{users === null && !error ? 'Loading results…' : `${totalFiles.toLocaleString()} files from ${(filtered?.length ?? 0).toLocaleString()} users${ anyFilter ? ' (filtered)' : '' }${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
{/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */} {users && users.length > 0 && (
setFilters((f) => ({ ...f, text: ev.target.value }))} placeholder="Filter files… (space-separated, prefix - to exclude)" className="h-9 w-full rounded-lg border bg-background pl-9 pr-8 text-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20" /> {filters.text && ( )}
setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}> Free slots setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}> Hide locked {EXT_PILLS.map((ext) => ( toggleExt(ext)}> {ext.toUpperCase()} ))} {BITRATE_PILLS.map((br) => ( setMinBitrate(br)}> ≥{br} ))} {anyFilter && ( )}
)}
{error &&

Could not load results: {error}

} {!error && users === null && (
Loading results…
)} {!error && users && users.length === 0 && (

{isComplete ? ( 'No results stored.' ) : ( <> Waiting for responses… )}

)} {!error && users && users.length > 0 && filtered && filtered.length === 0 && (

No files match the current filters.

)}
{shown.map((match) => ( toggle(match.user.username)} queued={queued} onDownload={queue} revealed={revealed} onToggleReveal={toggleReveal} /> ))}
); }; type UserCardProps = { match: UserMatch; open: boolean; onToggle: () => void; queued: Set; onDownload: (username: string, files: ResultFile[]) => void; revealed: Set; onToggleReveal: (key: string) => void; }; const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => { const { user, folders, matchedCount } = match; // A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings // the non-matching folders back, each already whole. const allFolders = revealed.has(userKey(user.username)); const hiddenFolders = user.folders.length - folders.length; const visible: FolderMatch[] = allFolders ? user.folders.map( (folder) => folders.find((fm) => fm.folder.path === folder.path) ?? { folder, matched: folder.files, matchedSize: folder.size, }, ) : folders; // Every action queues what is on screen, expanded folders included — never the hidden remainder. const shownFiles = visible.flatMap((fm) => revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched, ); const meta = ( <> {user.hasFreeUploadSlot ? ( free ) : ( {user.queueLength} )} {user.uploadSpeed > 0 && {formatSpeed(user.uploadSpeed)}} {matchedCount < user.fileCount ? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files` : `${user.fileCount.toLocaleString()} files`} ); return ( } meta={meta} /> {open && ( {visible.map((fm) => ( onToggleReveal(folderKey(user.username, fm.folder.path))} queued={queued} onDownload={onDownload} /> ))} {hiddenFolders > 0 && ( )} {shownFiles.length > 1 && ( )} )} ); }; type FolderBlockProps = { username: string; match: FolderMatch; revealed: boolean; onToggleReveal: () => void; queued: Set; onDownload: (username: string, files: ResultFile[]) => void; }; const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => { const { folder, matched, matchedSize } = match; const [open, setOpen] = useState(true); const files = revealed ? folder.files : matched; const hidden = folder.files.length - matched.length; const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null; const unlocked = files.filter((f) => !f.isLocked).length; return ( setOpen((v) => !v)} icon={} label={folder.label} title={folder.path} meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize( revealed ? folder.size : matchedSize, )}`} action={ files.length > 1 ? ( ) : null } /> {open && ( <> {/* The point of the whole exercise: the track you filtered for has found the album, so open the album. Revealing also widens every download button below to the full folder. */} {hidden > 0 && ( )} {files.map((file) => { const isQueued = queued.has(fileKey(username, file.filename)); const duration = formatDuration(file.length); // In an expanded folder, keep the file you filtered for legible among its neighbours. const isMatch = !matchedNames || matchedNames.has(file.filename); return (
{file.isLocked && } {file.name}
{file.bitRate ? {file.bitRate} kbps : null} {duration && {duration}} {formatSize(file.size)}
); })}
{unlocked > 1 && ( )} )}
); };