Files
soulseek/web/SearchResults.tsx
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

615 lines
26 KiB
TypeScript

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<string, ResultUser[]>();
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<string>;
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<number>(SLSKD_REFRESH_CHANNEL, 0);
const cachedInit = resultsCache.get(searchId) ?? null;
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<SlskdSearchSummary | null>(initial ?? null);
const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false);
// Users start expanded; toggling collapses them.
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
const [queued, setQueued] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
// Folders the reader has expanded past the filter, keyed username::path.
const [revealed, setRevealed] = useState<Set<string>>(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<Set<string>>(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<SlskdResponse[]>(`/soulseek/api/v0/searches/${searchId}/responses`),
client.get<SlskdSearchSummary>(`/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 (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center gap-3 border-b bg-background/60 px-4 py-3">
<button
type="button"
onClick={onBack}
title="Back to history"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{summary?.searchText ?? initial?.searchText ?? '…'}</span>
{!isComplete && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> searching
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{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}` : ''}`}
</div>
</div>
<button
type="button"
onClick={() => refresh(false)}
disabled={loading}
title="Reload results"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */}
{users && users.length > 0 && (
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/20 px-3 py-2.5">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={filters.text}
onChange={(ev) => 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 && (
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, text: '' }))}
className="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Pill active={filters.freeOnly} onClick={() => setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}>
<Zap className="h-3 w-3" /> Free slots
</Pill>
<Pill active={filters.hideLocked} onClick={() => setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}>
<Lock className="h-3 w-3" /> Hide locked
</Pill>
<span className="mx-0.5 h-4 w-px bg-border" />
{EXT_PILLS.map((ext) => (
<Pill key={ext} active={filters.exts.has(ext)} onClick={() => toggleExt(ext)}>
{ext.toUpperCase()}
</Pill>
))}
<span className="mx-0.5 h-4 w-px bg-border" />
{BITRATE_PILLS.map((br) => (
<Pill key={br} active={filters.minBitrate === br} onClick={() => setMinBitrate(br)}>
≥{br}
</Pill>
))}
{anyFilter && (
<button
type="button"
onClick={() => setFilters(EMPTY_FILTERS)}
className="ml-1 inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" /> Clear
</button>
)}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{error && <p className="p-1 text-sm text-destructive">Could not load results: {error}</p>}
{!error && users === null && (
<div className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading results…
</div>
)}
{!error && users && users.length === 0 && (
<p className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
{isComplete ? (
'No results stored.'
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Waiting for responses…
</>
)}
</p>
)}
{!error && users && users.length > 0 && filtered && filtered.length === 0 && (
<p className="p-1 text-sm text-muted-foreground">No files match the current filters.</p>
)}
<div className="flex flex-col gap-2">
{shown.map((match) => (
<UserCard
key={match.user.username}
match={match}
open={open.has(match.user.username)}
onToggle={() => toggle(match.user.username)}
queued={queued}
onDownload={queue}
revealed={revealed}
onToggleReveal={toggleReveal}
/>
))}
</div>
</div>
</div>
);
};
type UserCardProps = {
match: UserMatch;
open: boolean;
onToggle: () => void;
queued: Set<string>;
onDownload: (username: string, files: ResultFile[]) => void;
revealed: Set<string>;
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 ? (
<span className="inline-flex items-center gap-1 text-success">
<Zap className="h-3 w-3" /> free
</span>
) : (
<span className="inline-flex items-center gap-1">
<Users className="h-3 w-3" /> {user.queueLength}
</span>
)}
{user.uploadSpeed > 0 && <span>{formatSpeed(user.uploadSpeed)}</span>}
<span className="tabular-nums">
{matchedCount < user.fileCount
? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files`
: `${user.fileCount.toLocaleString()} files`}
</span>
</>
);
return (
<Card>
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
{open && (
<CardBody>
{visible.map((fm) => (
<FolderBlock
key={fm.folder.path}
username={user.username}
match={fm}
revealed={revealed.has(folderKey(user.username, fm.folder.path))}
onToggleReveal={() => onToggleReveal(folderKey(user.username, fm.folder.path))}
queued={queued}
onDownload={onDownload}
/>
))}
{hiddenFolders > 0 && (
<button
type="button"
onClick={() => onToggleReveal(userKey(user.username))}
className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
{allFolders ? (
<>
<ChevronUp className="h-3.5 w-3.5" /> Show only the {folders.length} matching{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Show this peer's other {hiddenFolders}{' '}
{hiddenFolders === 1 ? 'folder' : 'folders'}
</>
)}
</button>
)}
{shownFiles.length > 1 && (
<button
type="button"
onClick={() => onDownload(user.username, shownFiles)}
className="flex items-center justify-center gap-1.5 rounded-lg border border-border bg-muted/20 py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<Download className="h-3.5 w-3.5" /> Download all ({shownFiles.filter((f) => !f.isLocked).length})
</button>
)}
</CardBody>
)}
</Card>
);
};
type FolderBlockProps = {
username: string;
match: FolderMatch;
revealed: boolean;
onToggleReveal: () => void;
queued: Set<string>;
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 (
<SubCard>
<SubCardHeader
open={open}
onToggle={() => setOpen((v) => !v)}
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
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 ? (
<button
type="button"
onClick={() => onDownload(username, files)}
title={revealed || hidden === 0 ? 'Download folder' : `Download the ${files.length} matching files`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<Download className="h-3.5 w-3.5" />
</button>
) : 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 && (
<button
type="button"
onClick={onToggleReveal}
className="flex w-full items-center justify-center gap-1.5 border-b border-border/50 bg-muted/20 py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
{revealed ? (
<>
<ChevronUp className="h-3.5 w-3.5" /> Show only the {matched.length} matching
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Show the whole folder ({folder.files.length} files)
</>
)}
</button>
)}
<RowList>
{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 (
<div
key={file.filename}
className={`flex items-center gap-3 px-3 py-1.5 pl-8 text-sm ${
isMatch ? 'text-foreground' : 'text-muted-foreground'
}`}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-muted-foreground" />}
<span className={`truncate ${isMatch && matchedNames ? 'font-medium text-foreground' : ''}`}>
{file.name}
</span>
</span>
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-muted-foreground">
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
{duration && <span>{duration}</span>}
<span className="w-16 text-right">{formatSize(file.size)}</span>
</div>
<button
type="button"
onClick={() => onDownload(username, [file])}
disabled={file.isLocked || isQueued}
title={isQueued ? 'Queued' : 'Download'}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{isQueued ? <Loader2 className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
</button>
</div>
);
})}
</RowList>
{unlocked > 1 && (
<button
type="button"
onClick={() => onDownload(username, files)}
className="flex w-full items-center justify-center gap-1.5 border-t border-border/50 py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<Download className="h-3.5 w-3.5" />
{revealed || hidden === 0 ? `Download folder (${unlocked})` : `Download ${unlocked} matching`}
</button>
)}
</>
)}
</SubCard>
);
};