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 (
- + {expandable ? ( + open ? ( + + ) : ( + + ) + ) : ( + + )} + {open ? ( + + ) : ( + + )} + + {node.label} + + + {node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `} + {node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)} + + + {node.subtreeFileCount > 0 && ( + enqueue.mutate({ path: node.name })} + busy={enqueue.isPending} + title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`} + /> + )} +
{open && ( <> @@ -355,17 +381,18 @@ const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodePro Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders.

)} - {node.fileCount > 0 && } + {node.fileCount > 0 && } )} ); }; -type DirFilesProps = { username: string; dirId: number; indent: number }; +type DirFilesProps = { username: string; dirId: number; path: string; indent: number }; -const DirFiles = ({ username, dirId, indent }: DirFilesProps) => { +const DirFiles = ({ username, dirId, path, indent }: DirFilesProps) => { const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId); + const enqueue = useSoulseekEnqueue(username); const pad = { paddingLeft: 12 + indent * 14 }; if (isPending) @@ -385,13 +412,22 @@ const DirFiles = ({ username, dirId, indent }: DirFilesProps) => { return (
{data.map((file) => ( -
+
{file.name} {formatSize(file.size)} + enqueue.mutate({ path, file: file.name })} + busy={enqueue.isPending} + title={`Queue ${file.name}`} + />
))}
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();