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

177 lines
7.9 KiB
TypeScript

import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from '../db';
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from '../db';
import { authHeaders, getSlskdConfig } from './upstream';
// Background share-tree fetches.
//
// slskd's GET /users/{u}/browse is a single blocking call that inlines every file of every folder —
// measured at 59 MB / 18k folders / 284k files for one real peer, and it can take minutes because it
// round-trips to that peer over the Soulseek network. Doing it from the browser means a tab that hangs
// and throws the result away on navigation.
//
// So it runs HERE instead: the route kicks this off and returns immediately, the fetch outlives the
// request (and the tab), and the result lands in Postgres for the UI to page through. The sidecar is a
// long-lived PM2 process, which is what makes "come back later and it's there" work at all.
// slskd's wire shape. Files carry `attributes` (bitrate/duration) that real peers leave empty, plus
// `code`/`attributeCount` noise; all of it is dropped at ingest.
type SlskdBrowseFile = { filename: string; size?: number; extension?: string };
type SlskdBrowseDir = { name: string; fileCount?: number; files?: SlskdBrowseFile[] };
type SlskdBrowsePayload = { directories?: SlskdBrowseDir[] } | SlskdBrowseDir[];
// One browse per (user, peer) at a time. A second click while one is running is a no-op rather than a
// duplicate multi-minute fetch of the same tree.
const inFlight = new Map<string, Promise<void>>();
const keyOf = (userId: number, username: string) => `${userId}:${username}`;
/** Peers currently being fetched, so a caller can report "already running" without racing the map. */
export const isBrowseRunning = (userId: number, username: string): boolean => inFlight.has(keyOf(userId, username));
// Generous, but not unbounded: a peer that accepts the connection and then stalls shouldn't pin a
// snapshot in 'pending' forever.
const BROWSE_TIMEOUT_MS = 10 * 60 * 1000;
const extensionOf = (file: SlskdBrowseFile): string => {
const raw = file.extension?.trim();
if (raw) return raw.replace(/^\./, '').toLowerCase();
const dot = file.filename.lastIndexOf('.');
return dot > 0 ? file.filename.slice(dot + 1).toLowerCase() : '';
};
/**
* Strip the response to what the UI actually renders. Locked directories are skipped: they're shares the
* peer won't serve us, so listing them would only offer downloads that always fail.
*/
function slim(payload: SlskdBrowsePayload): BrowseDirInput[] {
const dirs = Array.isArray(payload) ? payload : (payload?.directories ?? []);
return dirs.map((dir) => {
const files: BrowsedFile[] = (dir.files ?? []).map((f) => ({
// Browse gives a basename only, unlike search results' full remote path. Downloading one means
// rejoining it to the directory name with a backslash.
name: f.filename,
size: f.size ?? 0,
extension: extensionOf(f),
}));
return { name: dir.name, files };
});
}
const SEP = '\\';
/** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */
function splitPath(name: string): { parentPath: string | null; label: string; depth: number } {
const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name;
const cut = trimmed.lastIndexOf(SEP);
const depth = trimmed.split(SEP).filter(Boolean).length || 1;
// `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is
// neither null nor a real row, and the level query would never find it.
return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth };
}
/**
* Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a
* time instead of the platform recomputing the shape on every request.
*
* Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders)
* exactly one intermediate folder was missing, so they're nearly always present, but a single gap would
* make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder.
* Rollups: a parent's own file count is usually just cover art, so the number worth showing on a
* collapsed row is the subtree's, and that's summed bottom-up here rather than per request.
*/
export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
const byName = new Map<string, BrowseDirRow>();
const ensure = (name: string): BrowseDirRow => {
const existing = byName.get(name);
if (existing) return existing;
const { parentPath, label, depth } = splitPath(name);
const row: BrowseDirRow = {
name,
parentPath,
label,
depth,
childCount: 0,
fileCount: 0,
totalSize: 0,
subtreeFileCount: 0,
subtreeSize: 0,
files: [],
};
// Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow.
byName.set(name, row);
if (parentPath) ensure(parentPath);
return row;
};
for (const dir of dirs) {
const row = ensure(dir.name);
// Concatenated rather than assigned: a path repeated in the response would otherwise lose files.
row.files = row.files.length ? row.files.concat(dir.files) : dir.files;
row.fileCount = row.files.length;
row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0);
}
const rows = [...byName.values()];
for (const row of rows) {
row.subtreeFileCount = row.fileCount;
row.subtreeSize = row.totalSize;
}
// Deepest first, so every child is final before its parent absorbs it.
for (const row of [...rows].sort((a, b) => b.depth - a.depth)) {
const parent = row.parentPath ? byName.get(row.parentPath) : undefined;
if (!parent) continue;
parent.childCount += 1;
parent.subtreeFileCount += row.subtreeFileCount;
parent.subtreeSize += row.subtreeSize;
}
return rows;
}
async function run(userId: number, username: string, snapshotId: number): Promise<void> {
const cfg = await getSlskdConfig(userId);
if (!cfg) {
await failSoulseekBrowse(snapshotId, 'slskd upstream not configured');
return;
}
const started = Date.now();
try {
const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
headers: authHeaders(cfg),
signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS),
});
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 200);
throw new Error(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`);
}
const dirs = slim((await res.json()) as SlskdBrowsePayload);
const tree = buildTree(dirs);
await finishSoulseekBrowse(snapshotId, tree);
const files = dirs.reduce((n, d) => n + d.files.length, 0);
// Against distinct names, not the array length, so a repeated path doesn't read as a negative.
const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size;
console.log(
`[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${files} files in ${Date.now() - started}ms`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`);
await failSoulseekBrowse(snapshotId, message);
}
}
/**
* Start caching a peer's share tree and return once it's *registered*, not once it's done — the caller
* responds 202 and the fetch continues in the background. Resolves false if one was already running.
*/
export async function startBrowse(userId: number, username: string): Promise<boolean> {
const key = keyOf(userId, username);
if (inFlight.has(key)) return false;
const snapshotId = await startSoulseekBrowse(userId, username);
// Registered before awaiting anything inside run(), so two rapid clicks can't both get past the check.
const task = run(userId, username, snapshotId).finally(() => inFlight.delete(key));
inFlight.set(key, task);
return true;
}