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.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { SearchResults, dropCachedResults } from './SearchResults';
|
||||
import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
|
||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Which search
|
||||
// is open lives in `?search=<id>`, so rows are real links and the back button returns to the history.
|
||||
//
|
||||
// Submitting opens the new search's results straight away, the way slskd's own web UI does. That is not
|
||||
// only a nicety: a history row is only as fresh as the last list load, so a search you had just started
|
||||
// sat at 0 responses looking stalled, while the results view polls for as long as the search runs.
|
||||
|
||||
const HISTORY_POLL_MS = 4000;
|
||||
// A running search nobody is watching shouldn't poll forever (~3 minutes).
|
||||
const MAX_HISTORY_POLLS = 45;
|
||||
|
||||
const byNewest = (list: SlskdSearchSummary[]) => [...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
||||
|
||||
// slskd accepts a client-supplied id (its own UI depends on that), but don't bet the navigation on it:
|
||||
// prefer an id the server has actually stored, then fall back to the newest search with this text.
|
||||
type CreatedIdParams = { list: SlskdSearchSummary[] | null; sentId: string; returnedId?: string; text: string };
|
||||
const createdSearchId = ({ list, sentId, returnedId, text }: CreatedIdParams) => {
|
||||
if (!list) return returnedId ?? sentId;
|
||||
const known = new Set(list.map((s) => s.id));
|
||||
if (returnedId && known.has(returnedId)) return returnedId;
|
||||
if (known.has(sentId)) return sentId;
|
||||
return list.find((s) => s.searchText === text)?.id ?? returnedId ?? sentId;
|
||||
};
|
||||
|
||||
export const SearchView = () => {
|
||||
const client = useClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [history, setHistory] = useState<SlskdSearchSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const selectedId = params.get(SEARCH_PARAM);
|
||||
|
||||
const openSearch = useCallback(
|
||||
(id: string | null) =>
|
||||
setParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (id) next.set(SEARCH_PARAM, id);
|
||||
else next.delete(SEARCH_PARAM);
|
||||
return next;
|
||||
}),
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
const list = await client.get<SlskdSearchSummary[]>('/soulseek/api/v0/searches');
|
||||
return byNewest(list ?? []);
|
||||
// useClient() is a fresh object each render — keep this callback stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setError(null);
|
||||
fetchHistory()
|
||||
.then(setHistory)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [fetchHistory]);
|
||||
|
||||
// Re-read whenever the history is what's on screen — including a browser Back out of a search, which
|
||||
// no callback of ours sees.
|
||||
useEffect(() => {
|
||||
if (!selectedId) load();
|
||||
}, [selectedId, load]);
|
||||
|
||||
// A row's counters only move when the list is re-read, so keep re-reading while a search is running —
|
||||
// but not while its results are open, since that view polls on its own.
|
||||
const running = !!history?.some((s) => !s.isComplete);
|
||||
useEffect(() => {
|
||||
if (!running || selectedId) return;
|
||||
let polls = 0;
|
||||
const timer = setInterval(() => {
|
||||
polls += 1;
|
||||
if (polls > MAX_HISTORY_POLLS) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, HISTORY_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [running, selectedId, load]);
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const text = query.trim();
|
||||
if (!text || submitting) return;
|
||||
setSubmitting(true);
|
||||
const sentId = randomId();
|
||||
try {
|
||||
const created = await client.post<Partial<SlskdSearchSummary>>('/soulseek/api/v0/searches', {
|
||||
id: sentId,
|
||||
searchText: text,
|
||||
});
|
||||
// A failed reload shouldn't read as a failed search — fall through with what the POST told us.
|
||||
const list = await fetchHistory().catch(() => null);
|
||||
if (list) setHistory(list);
|
||||
setQuery('');
|
||||
openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text }));
|
||||
} catch (err) {
|
||||
toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
|
||||
dropCachedResults(id);
|
||||
if (selectedId === id) openSearch(null);
|
||||
client.delete(`/soulseek/api/v0/searches/${id}`).catch(() => load());
|
||||
};
|
||||
|
||||
// slskd has no bulk delete (0.26.0 exposes only DELETE /searches/{id}), so clearing fans out one
|
||||
// request per search and reconciles with a reload — partial failures come back on their own.
|
||||
const clearAll = async () => {
|
||||
const ids = history?.map((s) => s.id) ?? [];
|
||||
if (!ids.length || clearing) return;
|
||||
setConfirmClear(false);
|
||||
setClearing(true);
|
||||
setHistory([]);
|
||||
openSearch(null);
|
||||
ids.forEach(dropCachedResults);
|
||||
const settled = await Promise.allSettled(ids.map((id) => client.delete(`/soulseek/api/v0/searches/${id}`)));
|
||||
const failed = settled.filter((r) => r.status === 'rejected').length;
|
||||
if (failed) toast.error(`${failed} of ${ids.length} searches could not be removed.`);
|
||||
setClearing(false);
|
||||
load();
|
||||
};
|
||||
|
||||
// The destructive click is two-step rather than a modal (matching the other Soulseek panels, which
|
||||
// confirm nothing) — and the armed state disarms itself so it can't sit there waiting to be hit.
|
||||
useEffect(() => {
|
||||
if (!confirmClear) return;
|
||||
const timer = setTimeout(() => setConfirmClear(false), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [confirmClear]);
|
||||
|
||||
// The row we came from, when we have it — a search opened by URL (or just created) resolves its own.
|
||||
const selected = selectedId ? (history?.find((s) => s.id === selectedId) ?? null) : null;
|
||||
const hrefFor = (id: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(SEARCH_PARAM, id);
|
||||
return `?${next.toString()}`;
|
||||
};
|
||||
|
||||
if (selectedId) return <SearchResults searchId={selectedId} initial={selected} onBack={() => openSearch(null)} />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header + search field */}
|
||||
<div className="shrink-0 border-b bg-background/60 px-6 py-5">
|
||||
<h1 className="mb-3 text-lg font-semibold">Search</h1>
|
||||
<form onSubmit={submit} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Search artists, albums, tracks…"
|
||||
className="h-11 w-full rounded-xl border border-border bg-card pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !query.trim()}
|
||||
className="flex h-11 items-center gap-2 rounded-xl bg-primary px-5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<div className="flex shrink-0 items-center justify-between px-6 pb-2 pt-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Recent searches
|
||||
{history && <span>· {history.length}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!!history?.length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (confirmClear ? clearAll() : setConfirmClear(true))}
|
||||
onBlur={() => setConfirmClear(false)}
|
||||
disabled={clearing}
|
||||
title={confirmClear ? `Delete all ${history.length} searches` : 'Clear all searches'}
|
||||
className={`flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition disabled:opacity-50 ${
|
||||
confirmClear ? 'bg-destructive/15 text-destructive hover:bg-destructive/25' : 'text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||
{clearing ? 'Clearing…' : confirmClear ? `Delete ${history.length}?` : 'Clear all'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
title="Refresh"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||
{error && <p className="px-2 py-3 text-sm text-destructive">Could not load history: {error}</p>}
|
||||
{!error && history === null && (
|
||||
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!error && history?.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 px-2 py-16 text-center text-sm text-muted-foreground">
|
||||
<History className="h-8 w-8 opacity-40" />
|
||||
No searches yet — try one above.
|
||||
</div>
|
||||
)}
|
||||
{history && history.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{history.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm transition-colors hover:bg-muted"
|
||||
>
|
||||
{/* The row is an anchor so it opens with cmd-click and keyboard like any link; the remove
|
||||
button is its sibling, never nested inside it. */}
|
||||
<Link to={hrefFor(s.id)} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted/40 text-muted-foreground">
|
||||
{s.isComplete ? <Search className="h-4 w-4" /> : <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{s.searchText}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>{formatWhen(s.startedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{s.responseCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FileAudio className="h-3 w-3" />
|
||||
{s.fileCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(s.id)}
|
||||
title="Remove search"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user