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.
412 lines
15 KiB
TypeScript
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',
|
|
};
|