diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts
index 53b4b852..90072fc0 100644
--- a/src/databases/officer_db/src/index.ts
+++ b/src/databases/officer_db/src/index.ts
@@ -113,9 +113,11 @@ export {
getSoulseekBrowseLevel,
searchSoulseekBrowseTree,
getSoulseekBrowseDirFiles,
+ getSoulseekBrowseDownload,
deleteSoulseekBrowse,
} from './queries/soulseek';
export type {
+ BrowseDownloadFile,
BrowsedFile,
BrowseDirInput,
BrowseDirRow,
diff --git a/src/databases/officer_db/src/queries/soulseek.ts b/src/databases/officer_db/src/queries/soulseek.ts
index 60f12ae4..cc123151 100644
--- a/src/databases/officer_db/src/queries/soulseek.ts
+++ b/src/databases/officer_db/src/queries/soulseek.ts
@@ -343,6 +343,47 @@ export async function getSoulseekBrowseDirFiles({
return row ? (row.files as BrowsedFile[]) : null;
}
+/** A file as slskd wants it enqueued: the peer's own full path, plus the size it advertised. */
+export type BrowseDownloadFile = { filename: string; size: number };
+type BrowseDownloadParams = { userId: number; username: string; path: string; file?: string };
+
+/**
+ * Resolve a download request to the exact files to enqueue — one folder's subtree, or a single file.
+ *
+ * Sizes and paths come from the cache rather than the client: slskd matches a queued download on
+ * filename AND size, so a wrong number is a transfer that never starts. The stored file name is a
+ * basename (browse, unlike search, doesn't repeat the folder), so the peer's real path is rejoined here.
+ */
+export async function getSoulseekBrowseDownload({
+ userId,
+ username,
+ path,
+ file,
+}: BrowseDownloadParams): Promise {
+ const snapshotId = await snapshotIdOf(userId, username);
+ if (snapshotId === null) return [];
+
+ const rows = await db
+ .select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
+ .from(soulseekBrowseDirs)
+ .where(
+ and(
+ eq(soulseekBrowseDirs.snapshotId, snapshotId),
+ // starts_with, not LIKE: these paths are full of backslashes, and one would be a LIKE escape.
+ file
+ ? eq(soulseekBrowseDirs.name, path)
+ : sql`(${soulseekBrowseDirs.name} = ${path} or starts_with(${soulseekBrowseDirs.name}, ${path + SEP}))`,
+ ),
+ )
+ .orderBy(asc(soulseekBrowseDirs.name));
+
+ return rows.flatMap((row) =>
+ (row.files as BrowsedFile[])
+ .filter((f) => (file ? f.name === file : true))
+ .map((f) => ({ filename: `${row.name}${SEP}${f.name}`, size: f.size })),
+ );
+}
+
/** Drop a peer's cache entirely (snapshot + folders, via the FK cascade). */
export async function deleteSoulseekBrowse(userId: number, username: string): Promise {
await db
diff --git a/src/servers/sidecar/slskd/download.ts b/src/servers/sidecar/slskd/download.ts
new file mode 100644
index 00000000..8eb5c80f
--- /dev/null
+++ b/src/servers/sidecar/slskd/download.ts
@@ -0,0 +1,60 @@
+import { getSoulseekBrowseDownload } from 'officerdb';
+import { getSlskdBase, getSlskdApiKey } 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 {
+ const base = getSlskdBase();
+ if (!base) 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 headers = new Headers({ 'content-type': 'application/json' });
+ const apiKey = getSlskdApiKey();
+ if (apiKey) headers.set('X-API-Key', apiKey);
+
+ const res = await fetch(`${base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
+ method: 'POST',
+ headers,
+ 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;
+}
diff --git a/src/servers/sidecar/slskd/officer.ts b/src/servers/sidecar/slskd/officer.ts
index 701c4865..e2626818 100644
--- a/src/servers/sidecar/slskd/officer.ts
+++ b/src/servers/sidecar/slskd/officer.ts
@@ -10,6 +10,7 @@ import {
deleteSoulseekBrowse,
} from 'officerdb';
import { startBrowse } from './browse';
+import { enqueueFromCache, DownloadError } from './download';
// Officer-owned Soulseek routes — everything slskd itself has no concept of. These are served HERE, by
// the sidecar, not forwarded upstream: the platform API stays a pure auth-and-forward proxy forever, and
@@ -29,6 +30,7 @@ import { startBrowse } from './browse';
// GET /_officer/browse//tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent)
// GET /_officer/browse//tree/search?q=&limit= → { nodes, matched, total, truncated }
// GET /_officer/browse//dirs//files → BrowsedFile[] one folder's files
+// POST /_officer/browse//download { path, file? } → { queued } enqueue a folder, or one file
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We
@@ -145,6 +147,22 @@ async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams):
return Response.json(result);
}
+ // /_officer/browse//download — a folder's whole subtree, or one file inside it.
+ if (segments[2] === 'download' && segments.length === 3) {
+ if (req.method !== 'POST') return methodNotAllowed();
+ const body = (await req.json().catch(() => null)) as { path?: unknown; file?: unknown } | null;
+ const path = typeof body?.path === 'string' ? body.path.trim() : '';
+ if (!path) return badRequest('path required');
+ const file = typeof body?.file === 'string' && body.file.trim() ? body.file : undefined;
+ try {
+ const queued = await enqueueFromCache({ userId, username, path, file });
+ return Response.json({ queued });
+ } catch (err) {
+ if (err instanceof DownloadError) return Response.json({ error: err.message }, { status: err.status });
+ throw err;
+ }
+ }
+
if (segments[2] !== 'dirs') return notFound();
// /_officer/browse//dirs//files
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx
index d09a01b7..24dfa01e 100644
--- a/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx
@@ -17,6 +17,7 @@ import {
useSoulseekBrowseLevel,
useSoulseekBrowseSearch,
useSoulseekBrowseFiles,
+ useSoulseekEnqueue,
} from './useSoulseekBrowse';
import { formatSize, formatWhen, type SoulseekBrowseNode, type SoulseekBrowseSnapshot } from './shared';
@@ -75,6 +76,21 @@ export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot
);
};
+/** Queue-this button, shown on row hover. Kept out of the row's own button — nesting the two is invalid. */
+type QueueButtonProps = { onQueue: () => void; busy: boolean; title: string };
+
+const QueueButton = ({ onQueue, busy, title }: QueueButtonProps) => (
+
+);
+
/** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */
type Filtered = { childrenOf: Map; matched: Set };
@@ -248,6 +264,7 @@ type TreeNodeProps = {
const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodeProps) => {
const [limit, setLimit] = useState(LEVEL_LIMIT);
const [revealed, setRevealed] = useState(false);
+ const enqueue = useSoulseekEnqueue(username);
// Filtering stops at a revealed row: from here down it's ordinary lazy browsing, so opening a matched
// folder shows what's really inside it rather than the filtered skeleton — which is why you searched.
@@ -284,39 +301,48 @@ const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodePro
return (
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts
index 90a0f4a7..831ce928 100644
--- a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts
+++ b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts
@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { SoulseekBrowseSnapshot, SoulseekBrowseLevel, SoulseekBrowseSearch, SoulseekBrowsedFile } from './shared';
@@ -112,6 +113,27 @@ export function useSoulseekBrowseSearch({ username, q, enabled = true }: SearchP
});
}
+/** A folder (its whole subtree) or one file inside it, named the way the cache stores them. */
+export type SoulseekDownloadTarget = { path: string; file?: string };
+
+/**
+ * Queue a download from the cache. The sidecar expands a folder into its files and rejoins each one to
+ * its peer path, so this sends a path rather than a list — the browser never walks the subtree itself.
+ */
+export function useSoulseekEnqueue(username: string) {
+ const { post } = useClient();
+
+ return useMutation({
+ mutationFn: (target: SoulseekDownloadTarget) =>
+ post<{ queued: number }>(`/slskd/_officer/browse/${encodeURIComponent(username)}/download`, target),
+ onSuccess: ({ queued }, target) =>
+ toast.success(queued === 1 ? `Queued ${target.file ?? basename(target.path)}` : `Queued ${queued} files`),
+ onError: (err) => toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`),
+ });
+}
+
+const basename = (path: string) => path.slice(path.lastIndexOf('\\') + 1);
+
/** A single folder's files, fetched only when it's expanded. Cached indefinitely — the rows are immutable. */
export function useSoulseekBrowseFiles(username: string | null, dirId: number | null) {
const { get } = useClient();