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.
230 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
};
|