soulseek: render cached shares as a tree, not a path list

slskd's browse response is flat: every folder is a full backslash-delimited
path. Rendered as-is, a filter for "pogues" gave 26 rows that all began with
the same 31 characters, and the real hierarchy — which is the only way to tell
an artist folder from an album folder — was invisible.

The shape is now derived once, at ingest, in the sidecar: buildTree() links
each path to its parent, synthesizes any ancestor slskd omitted (measured:
exactly one missing across ~30k folders on two real peers, but a single gap
would strand a whole subtree), and rolls subtree file counts and sizes up
bottom-up. A parent's own files are usually just cover art, so the number
worth showing on a collapsed row is the subtree's.

Storing the shape rather than recomputing it is what lets the UI open one
level at a time. Levels are still paged, because fan-out is brutal — the
widest folder measured has 1,181 children.

Filtering keeps the tree instead of falling back to a list: the search route
returns matches plus every ancestor, and the UI renders that skeleton
pre-expanded, so you see where a hit lives. Matching runs against the whole
path, so a matched folder implies its descendants match too and a matched
subtree arrives complete. The match cap is reported in the payload and shown
in the UI rather than passed off as the whole answer.

The two existing snapshots were backfilled by scripts/rebuild-soulseek-tree.ts,
which runs the same buildTree + finishSoulseekBrowse the ingest path runs — no
second implementation to drift, and no peer contact needed. Kept for the next
time the tree shape changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 01:05:22 +00:00
co-authored by Claude Opus 4.8
parent 12b9641adc
commit 1159c0787d
9 changed files with 582 additions and 156 deletions
+32 -11
View File
@@ -4,7 +4,8 @@ import {
removeSoulseekFavorite,
getSoulseekBrowseSnapshots,
getSoulseekBrowseSnapshot,
getSoulseekBrowseDirs,
getSoulseekBrowseLevel,
searchSoulseekBrowseTree,
getSoulseekBrowseDirFiles,
deleteSoulseekBrowse,
} from 'officerdb';
@@ -25,7 +26,8 @@ import { startBrowse } from './browse';
// GET /_officer/browse/<u> → Snapshot | null one peer's cache state
// POST /_officer/browse/<u> → 202 { started } kick off a background fetch
// DELETE /_officer/browse/<u> → { ok: true } drop the cache
// GET /_officer/browse/<u>/dirs?q=&limit=&offset= → { dirs, total } a page of folders, no files
// 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
// ─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -47,8 +49,14 @@ const cleanUsername = (v: unknown): string | null => {
return CONTROL_CHARS.test(u) ? null : u;
};
const DIR_PAGE_MAX = 500;
const DIR_PAGE_DEFAULT = 100;
// A level, not a page: once you're inside a folder there's nothing to leaf through, so the UI asks for
// the whole level when it's wide. The cap is set above the widest fan-out measured on a real peer (1,181).
const DIR_PAGE_MAX = 2000;
const DIR_PAGE_DEFAULT = 200;
// A match cap, not a page: every match drags its ancestors along, so the response is up to ~depth times
// this. Truncation is reported in the payload rather than passed off as the whole answer.
const SEARCH_MATCH_MAX = 1000;
const SEARCH_MATCH_DEFAULT = 300;
const clampInt = (raw: string | null, fallback: number, min: number, max: number): number => {
const n = Number(raw);
if (!Number.isFinite(n)) return fallback;
@@ -107,21 +115,34 @@ async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams):
return methodNotAllowed();
}
if (segments[2] !== 'dirs') return notFound();
// /_officer/browse/<username>/dirs — a filtered page of folders, files excluded.
if (segments.length === 3) {
// /_officer/browse/<username>/tree — one level of folders, paged. No `parent` means the roots.
if (segments[2] === 'tree' && segments.length === 3) {
if (req.method !== 'GET') return methodNotAllowed();
const page = await getSoulseekBrowseDirs({
const level = await getSoulseekBrowseLevel({
userId,
username,
q: url.searchParams.get('q') ?? undefined,
parent: url.searchParams.get('parent') ?? undefined,
limit: clampInt(url.searchParams.get('limit'), DIR_PAGE_DEFAULT, 1, DIR_PAGE_MAX),
offset: clampInt(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER),
});
return Response.json(page);
return Response.json(level);
}
// /_officer/browse/<username>/tree/search — matches plus their ancestors, as one small tree. Kept off
// the level route because the shape differs: this one is a skeleton spanning depths, not a page.
if (segments[2] === 'tree' && segments.length === 4 && segments[3] === 'search') {
if (req.method !== 'GET') return methodNotAllowed();
const result = await searchSoulseekBrowseTree({
userId,
username,
q: url.searchParams.get('q') ?? '',
limit: clampInt(url.searchParams.get('limit'), SEARCH_MATCH_DEFAULT, 1, SEARCH_MATCH_MAX),
});
return Response.json(result);
}
if (segments[2] !== 'dirs') return notFound();
// /_officer/browse/<username>/dirs/<id>/files
if (segments.length === 5 && segments[4] === 'files') {
if (req.method !== 'GET') return methodNotAllowed();