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.
177 lines
7.9 KiB
TypeScript
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;
|
|
}
|