soulseek: queue downloads straight out of the cached tree
a browsed folder is a path, not a file list, and what clicking one means is "everything under here" — so the expansion happens in the sidecar, off the cache, rather than making the browser walk the tree a level at a time and rebuild paths it only half knows. browse reports file names as basenames, unlike search, so the peer's real path is rejoined from the folder row. sizes come from the cache too: slskd matches a queued download on filename AND size, so a number supplied by the client would be a transfer that silently never starts. a subtree can be the peer's whole share (284k files on one measured peer), so an over-limit request is refused with its count rather than truncated into a partial download nobody asked for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -113,9 +113,11 @@ export {
|
||||
getSoulseekBrowseLevel,
|
||||
searchSoulseekBrowseTree,
|
||||
getSoulseekBrowseDirFiles,
|
||||
getSoulseekBrowseDownload,
|
||||
deleteSoulseekBrowse,
|
||||
} from './queries/soulseek';
|
||||
export type {
|
||||
BrowseDownloadFile,
|
||||
BrowsedFile,
|
||||
BrowseDirInput,
|
||||
BrowseDirRow,
|
||||
|
||||
@@ -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<BrowseDownloadFile[]> {
|
||||
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<void> {
|
||||
await db
|
||||
|
||||
@@ -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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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/<u>/tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent)
|
||||
// GET /_officer/browse/<u>/tree/search?q=&limit= → { nodes, matched, total, truncated }
|
||||
// GET /_officer/browse/<u>/dirs/<id>/files → BrowsedFile[] one folder's files
|
||||
// POST /_officer/browse/<u>/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/<username>/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/<username>/dirs/<id>/files
|
||||
|
||||
@@ -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) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQueue}
|
||||
disabled={busy}
|
||||
title={title}
|
||||
className="mr-3 flex h-6 w-6 shrink-0 items-center justify-center rounded text-zinc-600 opacity-0 transition hover:bg-white/10 hover:text-primary focus:opacity-100 disabled:opacity-40 group-hover:opacity-100"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */
|
||||
type Filtered = { childrenOf: Map<string, SoulseekBrowseNode[]>; matched: Set<string> };
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandable && toggle()}
|
||||
style={{ paddingLeft: 12 + indent * 14 }}
|
||||
className={`flex w-full items-center gap-1.5 py-1.5 pr-4 text-left text-sm transition-colors hover:bg-white/[0.03] ${
|
||||
expandable ? '' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{expandable ? (
|
||||
open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{open ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-zinc-200'}`}
|
||||
title={node.name}
|
||||
<div className="group flex items-center transition-colors hover:bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandable && toggle()}
|
||||
style={{ paddingLeft: 12 + indent * 14 }}
|
||||
className={`flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left text-sm ${
|
||||
expandable ? '' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
|
||||
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
|
||||
</span>
|
||||
</button>
|
||||
{expandable ? (
|
||||
open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{open ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-zinc-200'}`}
|
||||
title={node.name}
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
|
||||
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
|
||||
</span>
|
||||
</button>
|
||||
{node.subtreeFileCount > 0 && (
|
||||
<QueueButton
|
||||
onQueue={() => enqueue.mutate({ path: node.name })}
|
||||
busy={enqueue.isPending}
|
||||
title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
@@ -355,17 +381,18 @@ const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodePro
|
||||
Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders.
|
||||
</p>
|
||||
)}
|
||||
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} indent={indent + 1} />}
|
||||
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} path={node.name} indent={indent + 1} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{data.map((file) => (
|
||||
<div key={file.name} style={pad} className="flex items-center gap-1.5 py-1 pr-4 text-xs">
|
||||
<div
|
||||
key={file.name}
|
||||
style={pad}
|
||||
className="group flex items-center gap-1.5 py-1 text-xs transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<span className="h-3.5 w-3.5 shrink-0" />
|
||||
<FileAudio className="h-3 w-3 shrink-0 text-zinc-600" />
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-400" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-zinc-500">{formatSize(file.size)}</span>
|
||||
<QueueButton
|
||||
onQueue={() => enqueue.mutate({ path, file: file.name })}
|
||||
busy={enqueue.isPending}
|
||||
title={`Queue ${file.name}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user