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
+11 -2
View File
@@ -110,11 +110,20 @@ export {
finishSoulseekBrowse,
failSoulseekBrowse,
resetStaleSoulseekBrowses,
getSoulseekBrowseDirs,
getSoulseekBrowseLevel,
searchSoulseekBrowseTree,
getSoulseekBrowseDirFiles,
deleteSoulseekBrowse,
} from './queries/soulseek';
export type { BrowsedFile, BrowseDirInput, BrowseDirPage, SoulseekBrowseSnapshot } from './queries/soulseek';
export type {
BrowsedFile,
BrowseDirInput,
BrowseDirRow,
BrowseTreeNode,
BrowseLevel,
BrowseTreeSearch,
SoulseekBrowseSnapshot,
} from './queries/soulseek';
export {
getVaultTokens,
setVaultTokens,
+145 -45
View File
@@ -1,4 +1,4 @@
import { eq, and, asc, ilike, sql } from 'drizzle-orm';
import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm';
import { db } from '../db';
import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from '../schema';
@@ -28,9 +28,26 @@ export async function removeSoulseekFavorite(userId: number, username: string):
/** A file inside a cached folder, slimmed at ingest (see the schema note on dropped attributes). */
export type BrowsedFile = { name: string; size: number; extension: string };
/** One folder to ingest: its slskd name, plus its files. Counts are derived here, not trusted. */
/** One folder as slskd gives it: a full backslash-delimited path, plus its files. */
export type BrowseDirInput = { name: string; files: BrowsedFile[] };
/**
* One folder as it's stored — a tree node. slskd's flat paths are linked up into this shape by the
* sidecar's `buildTree` before they get here, which is why every count is already on the row.
*/
export type BrowseDirRow = {
name: string;
parentPath: string | null;
depth: number;
label: string;
childCount: number;
fileCount: number;
totalSize: number;
subtreeFileCount: number;
subtreeSize: number;
files: BrowsedFile[];
};
export type SoulseekBrowseSnapshot = {
username: string;
status: string;
@@ -96,20 +113,17 @@ export async function startSoulseekBrowse(userId: number, username: string): Pro
// absurd. 18k folders is ~36 round trips at this size.
const INGEST_BATCH = 500;
/** Swap in a freshly fetched tree: replace the folder rows and mark the snapshot ready, atomically. */
export async function finishSoulseekBrowse(snapshotId: number, dirs: BrowseDirInput[]): Promise<void> {
const rows = dirs.map((d) => ({
snapshotId,
name: d.name,
fileCount: d.files.length,
totalSize: d.files.reduce((n, f) => n + (f.size || 0), 0),
files: d.files,
}));
/** Swap in a freshly built tree: replace the folder rows and mark the snapshot ready, atomically. */
export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise<void> {
const rows = tree.map((r) => ({ ...r, snapshotId }));
await db.transaction(async (tx) => {
await tx.delete(soulseekBrowseDirs).where(eq(soulseekBrowseDirs.snapshotId, snapshotId));
for (let i = 0; i < rows.length; i += INGEST_BATCH) {
await tx.insert(soulseekBrowseDirs).values(rows.slice(i, i + INGEST_BATCH));
}
// Summed over the roots, not over every row: the subtree rollups already include descendants, so
// adding them all up would count each file once per level above it.
const roots = rows.filter((r) => r.parentPath === null);
await tx
.update(soulseekBrowseSnapshots)
.set({
@@ -117,8 +131,8 @@ export async function finishSoulseekBrowse(snapshotId: number, dirs: BrowseDirIn
error: null,
completedAt: new Date(),
directoryCount: rows.length,
fileCount: rows.reduce((n, r) => n + r.fileCount, 0),
totalSize: rows.reduce((n, r) => n + r.totalSize, 0),
fileCount: roots.reduce((n, r) => n + r.subtreeFileCount, 0),
totalSize: roots.reduce((n, r) => n + r.subtreeSize, 0),
})
.where(eq(soulseekBrowseSnapshots.id, snapshotId));
});
@@ -145,11 +159,75 @@ export async function resetStaleSoulseekBrowses(): Promise<number> {
return rows.length;
}
export type BrowseDirPage = {
dirs: { id: number; name: string; fileCount: number; totalSize: number }[];
total: number;
/** A tree row as the UI renders it. `files` stays out — those load per folder, on expand. */
export type BrowseTreeNode = {
id: number;
name: string;
parentPath: string | null;
label: string;
depth: number;
childCount: number;
fileCount: number;
totalSize: number;
subtreeFileCount: number;
subtreeSize: number;
};
type BrowseDirPageParams = { userId: number; username: string; q?: string; limit: number; offset: number };
const treeCols = {
id: soulseekBrowseDirs.id,
name: soulseekBrowseDirs.name,
parentPath: soulseekBrowseDirs.parentPath,
label: soulseekBrowseDirs.label,
depth: soulseekBrowseDirs.depth,
childCount: soulseekBrowseDirs.childCount,
fileCount: soulseekBrowseDirs.fileCount,
totalSize: soulseekBrowseDirs.totalSize,
subtreeFileCount: soulseekBrowseDirs.subtreeFileCount,
subtreeSize: soulseekBrowseDirs.subtreeSize,
};
/** The snapshot a request is scoped to, or null if this peer was never fetched. */
async function snapshotIdOf(userId: number, username: string): Promise<number | null> {
const [snap] = await db
.select({ id: soulseekBrowseSnapshots.id })
.from(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
.limit(1);
return snap?.id ?? null;
}
export type BrowseLevel = { nodes: BrowseTreeNode[]; total: number };
type BrowseLevelParams = { userId: number; username: string; parent?: string; limit: number; offset: number };
/**
* One level of the tree: the children of `parent`, or the roots when it's absent. Paged, because fan-out
* is not gentle — the widest folder measured on a real peer has 1,181 children.
*/
export async function getSoulseekBrowseLevel(params: BrowseLevelParams): Promise<BrowseLevel> {
const { userId, username, parent, limit, offset } = params;
const snapshotId = await snapshotIdOf(userId, username);
if (snapshotId === null) return { nodes: [], total: 0 };
const where = and(
eq(soulseekBrowseDirs.snapshotId, snapshotId),
parent ? eq(soulseekBrowseDirs.parentPath, parent) : isNull(soulseekBrowseDirs.parentPath),
);
const [nodes, [count]] = await Promise.all([
db
.select(treeCols)
.from(soulseekBrowseDirs)
.where(where)
.orderBy(asc(soulseekBrowseDirs.name))
.limit(limit)
.offset(offset),
db
.select({ total: sql<number>`count(*)::int` })
.from(soulseekBrowseDirs)
.where(where),
]);
return { nodes, total: count?.total ?? 0 };
}
// These names come off strangers' filesystems, so they're littered with typographic punctuation that no
// keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'.
@@ -177,45 +255,67 @@ const likePattern = (term: string): string => `%${foldPunctuation(term).replace(
/** The column, folded the same way `likePattern` folds the term. */
const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`;
export type BrowseTreeSearch = {
/** Matches AND every ancestor of a match, so the UI has a whole tree to render, not orphans. */
nodes: BrowseTreeNode[];
/** `name`s of the rows that actually matched — the rest of `nodes` are there to hold them up. */
matched: string[];
/** Matches in the snapshot, which exceeds `matched.length` when the cap kicked in. */
total: number;
truncated: boolean;
};
type BrowseTreeSearchParams = { userId: number; username: string; q: string; limit: number };
const SEP = '\\';
/** Every path above `name`, longest first: 'a\b\c' → ['a\b', 'a']. */
function ancestorsOf(name: string): string[] {
const out: string[] = [];
for (let cut = name.lastIndexOf(SEP); cut > 0; cut = name.lastIndexOf(SEP, cut - 1)) {
out.push(name.slice(0, cut));
}
return out;
}
/**
* A page of a peer's cached folders, name-filtered. Files are excluded — that's the whole point of
* paging here, and they're fetched per folder on expand.
* Filtered view of the tree. Filtering never flattens it: matches come back with their full ancestor
* chains, which is what lets the UI open straight down to a hit and keep the shape visible around it.
*
* Ancestors are resolved by string prefix rather than a recursive CTE — the parent's path IS a prefix of
* the child's, so one indexed `name in (…)` lookup does what a walk would take `depth` round trips to do.
*/
export async function getSoulseekBrowseDirs(params: BrowseDirPageParams): Promise<BrowseDirPage> {
const { userId, username, q, limit, offset } = params;
const [snap] = await db
.select({ id: soulseekBrowseSnapshots.id })
.from(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
.limit(1);
if (!snap) return { dirs: [], total: 0 };
export async function searchSoulseekBrowseTree(params: BrowseTreeSearchParams): Promise<BrowseTreeSearch> {
const { userId, username, q, limit } = params;
const snapshotId = await snapshotIdOf(userId, username);
const term = q.trim();
if (snapshotId === null || !term) return { nodes: [], matched: [], total: 0, truncated: false };
const term = q?.trim();
// ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's
// ~18k rows — which is why folding the column here costs nothing on top.
const where = term
? and(eq(soulseekBrowseDirs.snapshotId, snap.id), sql`${foldedName} ilike ${likePattern(term)}`)
: eq(soulseekBrowseDirs.snapshotId, snap.id);
const where = and(eq(soulseekBrowseDirs.snapshotId, snapshotId), sql`${foldedName} ilike ${likePattern(term)}`);
const [dirs, [count]] = await Promise.all([
db
.select({
id: soulseekBrowseDirs.id,
name: soulseekBrowseDirs.name,
fileCount: soulseekBrowseDirs.fileCount,
totalSize: soulseekBrowseDirs.totalSize,
})
.from(soulseekBrowseDirs)
.where(where)
.orderBy(asc(soulseekBrowseDirs.name))
.limit(limit)
.offset(offset),
const [matches, [count]] = await Promise.all([
db.select(treeCols).from(soulseekBrowseDirs).where(where).orderBy(asc(soulseekBrowseDirs.name)).limit(limit),
db
.select({ total: sql<number>`count(*)::int` })
.from(soulseekBrowseDirs)
.where(where),
]);
return { dirs, total: count?.total ?? 0 };
const have = new Set(matches.map((m) => m.name));
const wanted = new Set<string>();
for (const m of matches) for (const a of ancestorsOf(m.name)) if (!have.has(a)) wanted.add(a);
const ancestors = wanted.size
? await db
.select(treeCols)
.from(soulseekBrowseDirs)
.where(and(eq(soulseekBrowseDirs.snapshotId, snapshotId), inArray(soulseekBrowseDirs.name, [...wanted])))
: [];
const nodes = [...matches, ...ancestors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
const total = count?.total ?? 0;
return { nodes, matched: matches.map((m) => m.name), total, truncated: total > matches.length };
}
/** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */
@@ -68,22 +68,41 @@ export const soulseekBrowseSnapshots = pgTable(
// One row per FOLDER, with that folder's files inlined as jsonb — not a row per file, which would be
// 284k rows per peer. Folders are what you page and filter through; files are only ever read for the
// folder you opened, so they ride along in the row.
//
// slskd hands back a flat list of backslash-delimited paths, so the tree is derived once at ingest and
// stored here rather than recomputed per request: `parentPath` links a row to its parent and the subtree
// rollups are precomputed, which is what lets the UI open one level at a time. Measured on real peers,
// the tree is already complete in slskd's response — exactly one intermediate folder was missing across
// 30k rows — but ingest synthesizes any that are absent so a level can never dead-end.
export const soulseekBrowseDirs = pgTable(
'soulseek_browse_dirs',
{
id: serial('id').primaryKey(),
snapshotId: integer('snapshot_id').notNull(),
name: text('name').notNull(),
// null for a root. Its parent's `name`, i.e. `name` minus the last backslash segment.
parentPath: text('parent_path'),
// Segment count, 1-based, so the UI can indent without walking back up the chain.
depth: integer('depth').notNull().default(1),
// Last path segment — what a tree row actually displays.
label: text('label').notNull().default(''),
childCount: integer('child_count').notNull().default(0),
fileCount: integer('file_count').notNull().default(0),
totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0),
// This folder AND everything under it. A parent's own file count is usually cover art; the number
// worth showing on a collapsed row is the whole subtree's.
subtreeFileCount: integer('subtree_file_count').notNull().default(0),
subtreeSize: bigint('subtree_size', { mode: 'number' }).notNull().default(0),
// BrowsedFile[] — slimmed at ingest to {name,size,extension}: slskd's browse response carries
// per-file `attributes` (bitrate/duration) that real peers leave empty, and dropping them plus the
// wire noise took the sample from 59 MB to 19 MB.
files: jsonb('files').notNull().default([]),
},
(t) => [
// Both reads are "this snapshot's folders, ordered/filtered by name".
// Filtered search and the name lookups behind ancestor expansion.
index('idx_soulseek_browse_dirs_snapshot_name').on(t.snapshotId, t.name),
// The tree's hot path: one level, ordered by name. Covers "the roots" too, via a null parent_path.
index('idx_soulseek_browse_dirs_snapshot_parent').on(t.snapshotId, t.parentPath, t.name),
// Named explicitly: drizzle's derived name for this pair would be 64 chars, one over Postgres's
// 63-char identifier cap, so it would be silently truncated and never match the schema again.
foreignKey({