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.
497 lines
20 KiB
TypeScript
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>
|
|
);
|
|
};
|