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

230 lines
8.4 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { useClient } from 'hooks/useClient';
import { RefreshCw, X, Folder, ArrowUpFromLine } from 'lucide-react';
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards';
import {
basename,
folderLabel,
formatSize,
formatSpeed,
transferPhase,
toneSolid,
PHASE_TONE,
type SlskdTransfer,
type SlskdTransferUser,
type TransferPhase,
} from './shared';
// Uploads panel — the mirror image of Downloads: what peers are pulling from us. GET /transfers/uploads
// returns the same user → directories → files shape, so we group and render it the same way (collapsible
// card per peer, files by folder, a progress bar per file). Basic version: one flat list, per-row
// cancel/clear; no retry/queue-position controls (those are download-side concerns).
const POLL_MS = 1500;
type Row = SlskdTransfer & { phase: TransferPhase };
type UpDir = { directory: string; label: string; files: Row[]; size: number };
type UpUser = { username: string; dirs: UpDir[]; 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: 'Uploading',
queued: 'Queued',
done: 'Done',
failed: 'Failed',
};
const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed';
const group = (users: SlskdTransferUser[]): UpUser[] => {
const out: UpUser[] = [];
for (const user of users) {
const dirs: UpDir[] = [];
for (const dir of user.directories ?? []) {
const files: Row[] = (dir.files ?? []).map((f) => ({ ...f, phase: transferPhase(f.state) }));
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 });
}
out.sort(
(a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username),
);
return out;
};
export const SoulseekUploads = () => {
const client = useClient();
const [data, setData] = useState<SlskdTransferUser[]>([]);
const [loaded, setLoaded] = useState(false);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const users = useMemo(() => group(data), [data]);
useEffect(() => {
let cancelled = false;
const tick = () =>
client
.get<SlskdTransferUser[]>('/soulseek/api/v0/transfers/uploads')
.then((u) => !cancelled && (setData(u), setLoaded(true)))
.catch(() => {});
tick();
const timer = setInterval(tick, POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
// useClient() is a fresh object each render — poll on a stable interval only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggle = (name: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
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)) })),
})),
);
// Abort an in-flight upload, or clear a finished/failed one from the queue.
const remove = async (row: Row) => {
const done = isRemovable(row.phase);
try {
await client.delete(
`/soulseek/api/v0/transfers/uploads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`,
);
dropIds(new Set([row.id]));
} catch {
/* next poll reconciles */
}
};
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center gap-2 border-b p-3 text-sm">
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">Uploads</span>
<span className="text-muted-foreground">· {users.length} peer{users.length === 1 ? '' : 's'}</span>
{!loaded && <RefreshCw className="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
{loaded ? 'No uploads. When a peer downloads a shared file it shows up here.' : 'Loading uploads…'}
</p>
) : (
<div className="flex flex-col gap-2">
{users.map((user) => (
<UserCard
key={user.username}
user={user}
open={!collapsed.has(user.username)}
onToggle={() => toggle(user.username)}
onRemove={remove}
/>
))}
</div>
)}
</div>
</div>
);
};
type UserCardProps = { user: UpUser; open: boolean; onToggle: () => void; onRemove: (row: Row) => void };
const UserCard = ({ user, open, onToggle, onRemove }: UserCardProps) => {
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} title={user.username} meta={meta} />
{open && (
<CardBody>
{user.dirs.map((dir) => (
<FolderBlock key={dir.directory} dir={dir} onRemove={onRemove} />
))}
</CardBody>
)}
</Card>
);
};
type FolderBlockProps = { dir: UpDir; onRemove: (row: Row) => void };
const FolderBlock = ({ dir, onRemove }: 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">
<span>{PHASE_LABEL[row.phase]}</span>
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
{speed && <span>· {speed}</span>}
</div>
</div>
);
})}
</RowList>
)}
</SubCard>
);
};