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

57 lines
2.3 KiB
TypeScript

import { getSoulseekBrowseDownload } from '../db';
import { authHeaders, getSlskdConfig } from './upstream';
// Enqueueing downloads out of a cached share tree.
//
// slskd's own enqueue endpoint takes a flat list of {filename, size}, which is fine for search results —
// they arrive as individual files with full paths. A browsed folder is neither: it's a path, and what the
// user means by clicking it is "everything under here". Expanding that into a file list needs the cache,
// so it happens HERE rather than in the browser, which would otherwise have to walk the tree one level
// request at a time and reconstruct paths it only half knows.
/**
* A folder can be enormous — the root of one measured peer is 284,166 files. Enqueueing that would be a
* misclick that takes the slskd instance down with it, so it's refused with the count rather than
* truncated to a silent partial download.
*/
export const MAX_ENQUEUE = 10_000;
export class DownloadError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
type EnqueueParams = { userId: number; username: string; path: string; file?: string };
/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */
export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise<number> {
const cfg = await getSlskdConfig(userId);
if (!cfg) throw new DownloadError('slskd upstream not configured', 503);
const files = await getSoulseekBrowseDownload({ userId, username, path, file });
if (!files.length) throw new DownloadError('nothing to download at that path', 404);
if (files.length > MAX_ENQUEUE) {
throw new DownloadError(
`${files.length.toLocaleString()} files is over the ${MAX_ENQUEUE.toLocaleString()} limit`,
413,
);
}
const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders(cfg) },
body: JSON.stringify(files),
});
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 200);
throw new DownloadError(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`, 502);
}
console.log(`[slskd] queued ${files.length} file(s) from ${username}: ${file ?? path}`);
return files.length;
}