Files
soulseek/web/SoulseekTransfers.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

497 lines
20 KiB
TypeScript

import type { ReactNode } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react';
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards';
import { UserMenu } from './UserMenu';
import {
SLSKD_REFRESH_CHANNEL,
basename,
folderLabel,
formatSize,
formatSpeed,
transferPhase,
toneSolid,
PHASE_TONE,
type SlskdApplication,
type SlskdDownloadUser,
type SlskdTransfer,
type TransferPhase,
} from './shared';
// Right panel of the /soulseek workspace — the connection status strip plus the live download queue.
// Downloads are polled from slskd (GET /transfers/downloads, nested user → directories → files) and
// presented the same way as search results: a collapsible card per user, files grouped by folder.
// Enqueuing from the search panel bumps the refresh channel so the list updates without waiting for the
// next poll tick.
const POLL_MS = 1500;
type Row = SlskdTransfer & { phase: TransferPhase };
type DlDir = { directory: string; label: string; files: Row[]; size: number };
type DlUser = { username: string; dirs: DlDir[]; counts: Record<TransferPhase, number>; total: number };
const PHASE_ORDER: Record<TransferPhase, number> = { downloading: 0, queued: 1, failed: 2, done: 3 };
const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done'];
const PHASE_LABEL: Record<TransferPhase, string> = {
downloading: 'Downloading',
queued: 'Queued',
done: 'Done',
failed: 'Failed',
};
const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed';
// Group raw download users → dirs → files, keeping only files whose phase passes `keep`. Users/dirs
// that end up empty are dropped, so passing a done/not-done predicate splits the queue into sections.
const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boolean): DlUser[] => {
const out: DlUser[] = [];
for (const user of users) {
const dirs: DlDir[] = [];
for (const dir of user.directories ?? []) {
const files: Row[] = (dir.files ?? [])
.map((f) => ({ ...f, phase: transferPhase(f.state) }))
.filter((f) => keep(f.phase));
if (files.length === 0) continue;
files.sort(
(a, b) =>
PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)),
);
dirs.push({
directory: dir.directory,
label: folderLabel(dir.directory),
files,
size: files.reduce((n, f) => n + f.size, 0),
});
}
if (dirs.length === 0) continue;
dirs.sort((a, b) => a.directory.localeCompare(b.directory));
const counts: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
let total = 0;
for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++);
out.push({ username: user.username, dirs, counts, total });
}
// Active users first, then alphabetical.
out.sort(
(a, b) =>
Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username),
);
return out;
};
export const SoulseekTransfers = () => {
const client = useClient();
const [refresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
const [app, setApp] = useState<SlskdApplication | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [data, setData] = useState<SlskdDownloadUser[]>([]);
// Users are open by default; collapsing a live-updating list is opt-in so new users appear expanded.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Two sections: everything still in flight (downloading / queued / failed) on top, finished
// (succeeded) collected at the bottom. A user with a mix appears in both.
const active = useMemo(() => group(data, (p) => p !== 'done'), [data]);
const completed = useMemo(() => group(data, (p) => p === 'done'), [data]);
const doneCount = useMemo(() => completed.reduce((n, u) => n + u.total, 0), [completed]);
// Queue-wide phase tallies drive the bulk toolbar. Cancelled transfers collapse to `failed`, so the
// three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done.
const totals = useMemo(() => {
const t: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
for (const u of data)
for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
return t;
}, [data]);
const inFlight = totals.downloading + totals.queued;
const loadStatus = useCallback(() => {
setStatusLoading(true);
setStatusError(null);
client
.get<SlskdApplication>('/soulseek/api/v0/application')
.then((d) => setApp(d))
.catch((err) => setStatusError(err instanceof Error ? err.message : String(err)))
.finally(() => setStatusLoading(false));
// useClient() is a fresh object each render — keep this callback stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
// Poll the download queue; also refetch immediately when the search panel bumps the refresh nonce.
useEffect(() => {
let cancelled = false;
const tick = () =>
client
.get<SlskdDownloadUser[]>('/soulseek/api/v0/transfers/downloads')
.then((u) => !cancelled && setData(u))
.catch(() => {});
tick();
const timer = setInterval(tick, POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
// useClient() is a fresh object each render — poll on the refresh nonce only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh]);
// Collapse keys are section-qualified ("a:<user>" / "c:<user>") so the same peer can be open in the
// active section and collapsed in completed independently.
const toggle = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const dropIds = (ids: Set<string>) =>
setData((prev) =>
prev.map((u) => ({
...u,
directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })),
})),
);
// For an active transfer this cancels it; for a finished/failed one it clears it from the queue.
const remove = async (row: Row) => {
const done = isRemovable(row.phase);
try {
await client.delete(
`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`,
);
dropIds(new Set([row.id]));
} catch {
/* next poll reconciles */
}
};
const clearCompleted = async (user: DlUser) => {
const finished = user.dirs.flatMap((d) => d.files).filter((f) => isRemovable(f.phase));
if (finished.length === 0) return;
await Promise.allSettled(
finished.map((f) =>
client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(user.username)}/${f.id}?remove=true`),
),
);
dropIds(new Set(finished.map((f) => f.id)));
};
const clearAllCompleted = async () => {
const finished = completed.flatMap((u) =>
u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))),
);
if (finished.length === 0) return;
await Promise.allSettled(
finished.map((f) =>
client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(f.username)}/${f.id}?remove=true`),
),
);
dropIds(new Set(finished.map((f) => f.id)));
};
// Cancel every in-flight transfer (no ?remove — they become cancelled/failed and the poll reconciles).
const cancelAll = async () => {
const targets: { username: string; id: string }[] = [];
for (const u of data)
for (const d of u.directories ?? [])
for (const f of d.files ?? []) {
const p = transferPhase(f.state);
if (p === 'downloading' || p === 'queued') targets.push({ username: u.username, id: f.id });
}
if (targets.length === 0) return;
await Promise.allSettled(
targets.map((t) => client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(t.username)}/${t.id}`)),
);
};
// Re-enqueue every errored/cancelled transfer, grouped into one POST per peer.
const retryErrored = async () => {
const byUser = new Map<string, { filename: string; size: number }[]>();
for (const u of data)
for (const d of u.directories ?? [])
for (const f of d.files ?? [])
if (transferPhase(f.state) === 'failed') {
const files = byUser.get(u.username) ?? [];
files.push({ filename: f.filename, size: f.size });
byUser.set(u.username, files);
}
if (byUser.size === 0) return;
const n = [...byUser.values()].reduce((s, a) => s + a.length, 0);
try {
await Promise.all(
[...byUser].map(([username, files]) =>
client.post(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(username)}`, files),
),
);
toast.success(n === 1 ? 'Retrying 1 download' : `Retrying ${n} downloads`);
} catch (err) {
toast.error(`Retry failed: ${err instanceof Error ? err.message : String(err)}`);
}
};
// Force slskd to re-request this download's place in the peer's queue, then patch the fresh value in.
const refreshPosition = async (row: Row) => {
try {
const t = await client.get<SlskdTransfer>(
`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}/position`,
);
setData((prev) =>
prev.map((u) =>
u.username !== row.username
? u
: {
...u,
directories: (u.directories ?? []).map((d) => ({
...d,
files: (d.files ?? []).map((f) => (f.id === row.id ? { ...f, placeInQueue: t.placeInQueue } : f)),
})),
},
),
);
toast.success(t.placeInQueue ? `Place in queue: #${t.placeInQueue}` : 'Queue position refreshed');
} catch (err) {
toast.error(`Couldn't fetch position: ${err instanceof Error ? err.message : String(err)}`);
}
};
const server = app?.server;
const connected = server?.isConnected ?? false;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b p-3">
<div className="flex min-w-0 items-center gap-2 text-sm">
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
{statusLoading && !app ? (
<span className="text-muted-foreground">Checking slskd…</span>
) : statusError ? (
<span className="truncate text-destructive">slskd unreachable: {statusError}</span>
) : (
<>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${connected ? 'bg-success' : 'bg-destructive'}`}
/>
<span className="truncate">{server?.state ?? 'unknown'}</span>
{server?.address && <span className="truncate text-muted-foreground">· {server.address}</span>}
{app?.version?.current && (
<span className="shrink-0 text-muted-foreground">· v{app.version.current}</span>
)}
</>
)}
</div>
<button
type="button"
onClick={loadStatus}
title="Refresh status"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RefreshCw className={`h-3.5 w-3.5 ${statusLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{(active.length > 0 || completed.length > 0) && (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
<BulkButton
icon={<RotateCcw className="h-3.5 w-3.5" />}
label="Retry errored"
count={totals.failed}
onClick={retryErrored}
/>
<BulkButton icon={<Ban className="h-3.5 w-3.5" />} label="Cancel all" count={inFlight} onClick={cancelAll} />
<BulkButton
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Remove completed"
count={totals.done}
onClick={clearAllCompleted}
/>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{active.length === 0 && completed.length === 0 ? (
<p className="text-sm text-muted-foreground">No downloads yet. Search and queue a file to see it here.</p>
) : (
<div className="flex flex-col gap-4">
{active.length > 0 && (
<section className="flex flex-col gap-2">
<div className="px-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active ({active.length})
</div>
{active.map((user) => (
<UserCard
key={user.username}
user={user}
open={!collapsed.has(`a:${user.username}`)}
onToggle={() => toggle(`a:${user.username}`)}
onRemove={remove}
onPosition={refreshPosition}
onClearCompleted={() => clearCompleted(user)}
/>
))}
</section>
)}
{completed.length > 0 && (
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between px-0.5">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Completed ({doneCount})
</div>
<button
type="button"
onClick={clearAllCompleted}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<X className="h-3 w-3" /> Clear all
</button>
</div>
{completed.map((user) => (
<UserCard
key={user.username}
user={user}
open={!collapsed.has(`c:${user.username}`)}
onToggle={() => toggle(`c:${user.username}`)}
onRemove={remove}
onPosition={refreshPosition}
onClearCompleted={() => clearCompleted(user)}
/>
))}
</section>
)}
</div>
)}
</div>
</div>
);
};
type BulkButtonProps = { icon: ReactNode; label: string; count: number; onClick: () => void };
const BulkButton = ({ icon, label, count, onClick }: BulkButtonProps) => (
<button
type="button"
onClick={onClick}
disabled={count === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:cursor-default disabled:opacity-40 disabled:hover:bg-muted/20 disabled:hover:text-muted-foreground"
>
{icon}
<span>{label}</span>
{count > 0 && <span className="rounded bg-muted px-1 tabular-nums text-muted-foreground">{count}</span>}
</button>
);
type UserCardProps = {
user: DlUser;
open: boolean;
onToggle: () => void;
onRemove: (row: Row) => void;
onPosition: (row: Row) => void;
onClearCompleted: () => void;
};
const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted }: UserCardProps) => {
const finished = user.counts.done + user.counts.failed;
const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => (
<span key={p} className="inline-flex items-center gap-1" title={PHASE_LABEL[p]}>
<span className={`h-2 w-2 rounded-full ${toneSolid[PHASE_TONE[p]]}`} />
{user.counts[p]}
</span>
));
return (
<Card>
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
{open && (
<CardBody>
{user.dirs.map((dir) => (
<FolderBlock key={dir.directory} dir={dir} onRemove={onRemove} onPosition={onPosition} />
))}
{finished > 0 && (
<button
type="button"
onClick={onClearCompleted}
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"
>
<X className="h-3.5 w-3.5" /> Clear completed ({finished})
</button>
)}
</CardBody>
)}
</Card>
);
};
type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: (row: Row) => void };
const FolderBlock = ({ dir, onRemove, onPosition }: FolderBlockProps) => {
const [open, setOpen] = useState(true);
return (
<SubCard>
<SubCardHeader
open={open}
onToggle={() => setOpen((v) => !v)}
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
label={dir.label}
title={dir.directory}
meta={`${dir.files.length} · ${formatSize(dir.size)}`}
/>
{open && (
<RowList>
{dir.files.map((row) => {
const dot = toneSolid[PHASE_TONE[row.phase]];
const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete)));
const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : '';
return (
<div key={row.id} className="px-3 py-2 pl-8 text-sm text-foreground">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
<span className="min-w-0 flex-1 truncate">{basename(row.filename)}</span>
<button
type="button"
onClick={() => onRemove(row)}
title={isRemovable(row.phase) ? 'Clear' : 'Cancel'}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full ${dot}`} style={{ width: `${pct}%` }} />
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{row.phase === 'queued' ? (
<button
type="button"
onClick={() => onPosition(row)}
title={
row.placeInQueue
? `Place in queue: #${row.placeInQueue} · click to refresh`
: 'Queued · click to fetch place in queue'
}
className="inline-flex items-center gap-1 rounded text-warning transition hover:text-warning/80"
>
<RefreshCw className="h-3 w-3" />
{row.placeInQueue ? `#${row.placeInQueue} in queue` : 'Queued'}
</button>
) : (
<span>{PHASE_LABEL[row.phase]}</span>
)}
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
{speed && <span>· {speed}</span>}
</div>
</div>
);
})}
</RowList>
)}
</SubCard>
);
};