Files
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

412 lines
15 KiB
TypeScript

// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/soulseek
// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here
// is a refresh signal, which is what channels are for.
import type { Tone } from '@/components/Data/tone';
import { useParams } from 'react-router';
// Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers
// panel refetches immediately instead of waiting for its next poll tick.
export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh';
export type SlskdApplication = {
version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean };
server?: { state?: string; address?: string; isConnected?: boolean };
};
export type SlskdSearch = {
id: string;
isComplete: boolean;
state: string;
fileCount: number;
responseCount: number;
};
export type SlskdFile = {
filename: string;
size: number;
extension?: string;
isLocked?: boolean;
bitRate?: number;
length?: number;
};
export type SlskdResponse = {
username: string;
fileCount: number;
files: SlskdFile[];
hasFreeUploadSlot: boolean;
queueLength: number;
uploadSpeed: number;
};
// One flat, rankable search result (a single file from a single peer).
export type ResultRow = {
username: string;
filename: string;
size: number;
extension: string;
isLocked: boolean;
bitRate?: number;
hasFreeUploadSlot: boolean;
queueLength: number;
uploadSpeed: number;
};
// slskd presents results grouped by responder (user) → folder → files. We mirror that shape.
export type ResultFile = {
filename: string; // full remote path (slskd needs this verbatim to enqueue)
name: string; // basename for display
size: number;
extension: string;
bitRate?: number;
length?: number;
isLocked: boolean;
};
export type ResultFolder = { path: string; label: string; files: ResultFile[]; size: number };
export type ResultUser = {
username: string;
hasFreeUploadSlot: boolean;
queueLength: number;
uploadSpeed: number;
fileCount: number;
folders: ResultFolder[];
};
// Group a search's responses into slskd's user → folder → file layout, best peers first.
export const groupResponses = (responses: SlskdResponse[]): ResultUser[] => {
const users: ResultUser[] = [];
for (const resp of responses) {
const byFolder = new Map<string, ResultFile[]>();
for (const f of resp.files ?? []) {
const parts = f.filename.split(/[\\/]/);
const name = parts.pop() ?? f.filename;
const path = parts.join('\\') || '\\';
const file: ResultFile = {
filename: f.filename,
name,
size: f.size,
extension: extOf(f),
bitRate: f.bitRate,
length: f.length,
isLocked: !!f.isLocked,
};
const arr = byFolder.get(path);
if (arr) arr.push(file);
else byFolder.set(path, [file]);
}
const folders: ResultFolder[] = [...byFolder.entries()]
.map(([path, files]) => {
files.sort((a, b) => a.name.localeCompare(b.name));
return { path, label: folderLabel(path), files, size: files.reduce((n, f) => n + f.size, 0) };
})
.sort((a, b) => a.path.localeCompare(b.path));
users.push({
username: resp.username,
hasFreeUploadSlot: resp.hasFreeUploadSlot,
queueLength: resp.queueLength,
uploadSpeed: resp.uploadSpeed,
fileCount: resp.fileCount ?? resp.files?.length ?? 0,
folders,
});
}
// Best peer first: free slot, then faster upload, then more files.
users.sort(
(a, b) =>
Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) ||
b.uploadSpeed - a.uploadSpeed ||
b.fileCount - a.fileCount,
);
return users;
};
// A download transfer. GET /transfers/downloads nests these as user → directories → files.
export type SlskdTransfer = {
id: string;
username: string;
direction: string;
filename: string;
size: number;
state: string;
bytesTransferred: number;
averageSpeed: number;
percentComplete: number;
placeInQueue?: number;
};
export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] };
export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] };
// Uploads (GET /transfers/uploads) use the same user → directories → files shape as downloads.
export type SlskdTransferUser = SlskdDownloadUser;
// Private conversations. GET /conversations lists them (one per peer); GET /conversations/{user}/messages
// returns that thread. A message's direction ('Out' = sent by us) drives self-alignment in the UI.
export type SlskdPrivateMessage = {
id: number;
timestamp: string;
username: string;
direction: string; // 'In' | 'Out'
message: string;
isAcknowledged?: boolean;
};
export type SlskdConversation = {
username: string;
isActive?: boolean;
unAcknowledgedMessageCount?: number;
hasUnAcknowledgedMessages?: boolean;
messages?: SlskdPrivateMessage[];
};
// A peer, as seen through the Users section. GET /users/{u}/info + /status; /browse returns their shares.
export type SlskdUserInfo = {
description?: string;
hasFreeUploadSlot?: boolean;
hasPicture?: boolean;
queueLength?: number;
uploadSlots?: number;
};
export type SlskdUserStatus = {
isPrivileged?: boolean;
presence?: string; // 'Offline' | 'Away' | 'Online'
};
export type SlskdBrowseDirectory = { name: string; fileCount: number; files?: SlskdFile[] };
// GET /users/{u}/browse wraps the share tree rather than returning a bare array, and inlines every
// file of every folder in one shot (real peers hit 18k folders / tens of MB). Note that a browsed
// file's `filename` is only the basename — unlike search results, where it's the full remote path —
// so enqueuing one means rejoining it to its directory name with a backslash.
export type SlskdBrowseResponse = {
directories: SlskdBrowseDirectory[];
directoryCount: number;
lockedDirectories: SlskdBrowseDirectory[];
lockedDirectoryCount: number;
};
// ── Cached share trees (Officer's own, from the sidecar's /_officer/browse routes) ──
//
// A live browse is one blocking slskd call carrying every file of every folder — 59 MB / 18k folders /
// 284k files for a real peer, thrown away the moment you navigate. So the sidecar fetches it in the
// background into Postgres and the UI reads paged slices. Timestamps arrive as ISO strings.
export type SoulseekBrowseStatus = 'pending' | 'ready' | 'failed';
export type SoulseekBrowseSnapshot = {
username: string;
status: SoulseekBrowseStatus;
error: string | null;
directoryCount: number;
fileCount: number;
totalSize: number;
startedAt: string;
completedAt: string | null;
};
// A node of the stored tree. The sidecar links slskd's flat backslash paths up at ingest, so `label` is
// already just the last segment and the subtree rollups are already summed — this side never derives
// either, which is the point: a collapsed row can show what's underneath it without fetching it.
export type SoulseekBrowseNode = {
id: number;
name: string;
parentPath: string | null;
label: string;
depth: number;
childCount: number;
fileCount: number;
totalSize: number;
subtreeFileCount: number;
subtreeSize: number;
};
export type SoulseekBrowseLevel = { nodes: SoulseekBrowseNode[]; total: number };
/** A filtered tree: the matches plus every ancestor holding them up, so the shape survives filtering. */
export type SoulseekBrowseSearch = {
nodes: SoulseekBrowseNode[];
matched: string[];
total: number;
truncated: boolean;
};
// Browse carries no bitrate/duration (real peers send empty `attributes`), so unlike a search result
// there's no quality metadata to show — just name, size, extension.
export type SoulseekBrowsedFile = { name: string; size: number; extension: string };
// Server connection state (GET /server, and the server block of GET /application).
export type SlskdServerState = { address?: string; state?: string; isConnected?: boolean; username?: string };
// Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest.
export type SlskdRoomUser = { username: string };
export type SlskdRoomMessage = {
timestamp: string;
username: string;
message: string;
roomName?: string;
self?: boolean;
};
export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and
// neither publishes to the other. Mirrors slskd's own top-menu sections.
export type SoulseekSectionId =
| 'dashboard'
| 'search'
| 'downloads'
| 'uploads'
| 'rooms'
| 'chat'
| 'users'
| 'system'
| 'connection';
export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'search', label: 'Search' },
{ id: 'downloads', label: 'Downloads' },
{ id: 'uploads', label: 'Uploads' },
{ id: 'rooms', label: 'Rooms' },
{ id: 'chat', label: 'Chat' },
{ id: 'users', label: 'Users' },
{ id: 'system', label: 'System' },
{ id: 'connection', label: 'Connection' },
];
/** Where /soulseek lands, and where an unrecognised section redirects to. */
export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard';
export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId =>
SOULSEEK_SECTIONS.some((s) => s.id === value);
export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`;
// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before
// that lands.
export const useSoulseekSection = (): SoulseekSectionId => {
const { section } = useParams();
return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION;
};
// Which peer the Users section is showing — `/soulseek/users?user=<name>`.
//
// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a
// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the
// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not
// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate.
export const USER_PARAM = 'user';
export const soulseekUserPath = (username: string) =>
`${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`;
// Which room the Rooms section is showing — `/soulseek/rooms?room=<name>` — and which thread the Chat
// section is showing — `/soulseek/chat?peer=<name>`. Two params rather than one because they are two
// sections: they never appear together, and a shared name would make `?thing=` mean different kinds of
// thing depending on the path.
//
// Both rails used to auto-select the first entry on load, which is why they held the selection locally:
// there was nowhere else to put an answer the user had not given. The bare section is now a real state —
// "nothing open" — and both panels already had the empty pane to say so.
export const ROOM_PARAM = 'room';
export const PEER_PARAM = 'peer';
export const soulseekRoomPath = (name: string) =>
`${soulseekSectionPath('rooms')}?${ROOM_PARAM}=${encodeURIComponent(name)}`;
export const soulseekPeerPath = (username: string) =>
`${soulseekSectionPath('chat')}?${PEER_PARAM}=${encodeURIComponent(username)}`;
// A past search, as listed by GET /searches (no responses inlined).
// Which search's results are open — `/soulseek/search?search=<id>`, read from the URL rather than held in a
// panel channel or local state. See docs/navigation-audit.md.
export const SEARCH_PARAM = 'search';
export type SlskdSearchSummary = {
id: string;
searchText: string;
startedAt: string;
endedAt?: string;
fileCount: number;
lockedFileCount: number;
responseCount: number;
isComplete: boolean;
state: string;
};
export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path;
// Compact folder label for grouped views — the last two path segments (slskd paths use backslashes).
export const folderLabel = (path: string) => {
const segs = path.split(/[\\/]/).filter(Boolean);
return segs.slice(-2).join(' / ') || path;
};
// Compact "time ago" for search-history rows.
export const formatWhen = (iso?: string): string => {
if (!iso) return '';
const t = new Date(iso).getTime();
if (!Number.isFinite(t)) return '';
const s = Math.round((Date.now() - t) / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString();
};
// Wall-clock HH:MM for chat message timestamps.
export const formatClock = (iso?: string): string => {
if (!iso) return '';
const d = new Date(iso);
return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
};
export const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : '');
// Track length (seconds) → m:ss, as slskd shows it.
export const formatDuration = (sec?: number): string => {
if (!sec || sec < 0) return '';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
};
export const extOf = (file: SlskdFile) =>
(file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase();
// slskd serialises TransferStates as a compound flag string ("Completed, Succeeded", "Queued, Remotely",
// "InProgress", "Requested", …). Collapse it to the one bucket we render.
export type TransferPhase = 'downloading' | 'queued' | 'done' | 'failed';
export const transferPhase = (state: string): TransferPhase => {
const s = state.toLowerCase();
if (s.includes('inprogress') || s.includes('initializing')) return 'downloading';
if (s.includes('succeeded')) return 'done';
if (
s.includes('errored') ||
s.includes('cancelled') ||
s.includes('rejected') ||
s.includes('timedout') ||
s.includes('aborted')
)
return 'failed';
return 'queued'; // Requested / Queued / anything not yet resolved
};
// The one place a transfer phase becomes a colour. Transfers, uploads and the dashboard tiles all read it,
// so a phase cannot mean success in one panel and warning in the next.
export const PHASE_TONE: Record<TransferPhase, Tone> = {
downloading: 'info',
queued: 'warning',
done: 'success',
failed: 'danger',
};
// A solid dot or progress bar, which `toneFill` (a /15 tint) deliberately does not cover.
export const toneSolid: Record<Tone, string> = {
neutral: 'bg-muted-foreground',
success: 'bg-success',
warning: 'bg-warning',
danger: 'bg-destructive',
info: 'bg-info',
busy: 'bg-busy',
};