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
+79 -3
View File
@@ -1,4 +1,4 @@
import type { BrowseDirInput, BrowsedFile } from 'officerdb';
import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
import { getSlskdBase, getSlskdApiKey } from './upstream';
@@ -56,6 +56,77 @@ function slim(payload: SlskdBrowsePayload): BrowseDirInput[] {
});
}
const SEP = '\\';
/** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */
function splitPath(name: string): { parentPath: string | null; label: string; depth: number } {
const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name;
const cut = trimmed.lastIndexOf(SEP);
const depth = trimmed.split(SEP).filter(Boolean).length || 1;
// `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is
// neither null nor a real row, and the level query would never find it.
return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth };
}
/**
* Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a
* time instead of the platform recomputing the shape on every request.
*
* Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders)
* exactly one intermediate folder was missing, so they're nearly always present, but a single gap would
* make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder.
* Rollups: a parent's own file count is usually just cover art, so the number worth showing on a
* collapsed row is the subtree's, and that's summed bottom-up here rather than per request.
*/
export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
const byName = new Map<string, BrowseDirRow>();
const ensure = (name: string): BrowseDirRow => {
const existing = byName.get(name);
if (existing) return existing;
const { parentPath, label, depth } = splitPath(name);
const row: BrowseDirRow = {
name,
parentPath,
label,
depth,
childCount: 0,
fileCount: 0,
totalSize: 0,
subtreeFileCount: 0,
subtreeSize: 0,
files: [],
};
// Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow.
byName.set(name, row);
if (parentPath) ensure(parentPath);
return row;
};
for (const dir of dirs) {
const row = ensure(dir.name);
// Concatenated rather than assigned: a path repeated in the response would otherwise lose files.
row.files = row.files.length ? row.files.concat(dir.files) : dir.files;
row.fileCount = row.files.length;
row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0);
}
const rows = [...byName.values()];
for (const row of rows) {
row.subtreeFileCount = row.fileCount;
row.subtreeSize = row.totalSize;
}
// Deepest first, so every child is final before its parent absorbs it.
for (const row of [...rows].sort((a, b) => b.depth - a.depth)) {
const parent = row.parentPath ? byName.get(row.parentPath) : undefined;
if (!parent) continue;
parent.childCount += 1;
parent.subtreeFileCount += row.subtreeFileCount;
parent.subtreeSize += row.subtreeSize;
}
return rows;
}
async function run(userId: number, username: string, snapshotId: number): Promise<void> {
const base = getSlskdBase();
if (!base) {
@@ -78,9 +149,14 @@ async function run(userId: number, username: string, snapshotId: number): Promis
}
const dirs = slim((await res.json()) as SlskdBrowsePayload);
await finishSoulseekBrowse(snapshotId, dirs);
const tree = buildTree(dirs);
await finishSoulseekBrowse(snapshotId, tree);
const files = dirs.reduce((n, d) => n + d.files.length, 0);
console.log(`[slskd] browse ${username} -> ${dirs.length} dirs / ${files} files in ${Date.now() - started}ms`);
// Against distinct names, not the array length, so a repeated path doesn't read as a negative.
const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size;
console.log(
`[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${files} files in ${Date.now() - started}ms`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`);
+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();