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>
51 lines
2.0 KiB
TypeScript
51 lines
2.0 KiB
TypeScript
// Re-derive the stored tree shape for every cached share snapshot, from the folder rows already in
|
|
// Postgres — no peer contact, so it works with everyone offline and is deterministic.
|
|
//
|
|
// Written for the backfill when the tree columns were added, but kept because it's the answer to any
|
|
// future change in tree shape: it runs the SAME buildTree + finishSoulseekBrowse the sidecar's ingest
|
|
// runs, so there's no second implementation to drift.
|
|
//
|
|
// bun scripts/rebuild-soulseek-tree.ts
|
|
|
|
import type { BrowsedFile } from 'officerdb';
|
|
import { eq, asc } from 'drizzle-orm';
|
|
import { db, finishSoulseekBrowse } from 'officerdb';
|
|
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/schema';
|
|
import { buildTree } from '../src/servers/sidecar/slskd/browse';
|
|
|
|
const snapshots = await db
|
|
.select({
|
|
id: soulseekBrowseSnapshots.id,
|
|
userId: soulseekBrowseSnapshots.userId,
|
|
username: soulseekBrowseSnapshots.username,
|
|
status: soulseekBrowseSnapshots.status,
|
|
})
|
|
.from(soulseekBrowseSnapshots)
|
|
.orderBy(asc(soulseekBrowseSnapshots.id));
|
|
|
|
console.log(`${snapshots.length} snapshot(s) to rebuild`);
|
|
|
|
for (const snap of snapshots) {
|
|
const started = Date.now();
|
|
const rows = await db
|
|
.select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
|
|
.from(soulseekBrowseDirs)
|
|
.where(eq(soulseekBrowseDirs.snapshotId, snap.id));
|
|
|
|
if (!rows.length) {
|
|
console.log(` #${snap.id} ${snap.username}: no folder rows, skipped`);
|
|
continue;
|
|
}
|
|
|
|
const tree = buildTree(rows.map((r) => ({ name: r.name, files: r.files as BrowsedFile[] })));
|
|
await finishSoulseekBrowse(snap.id, tree);
|
|
const roots = tree.filter((t) => t.parentPath === null);
|
|
console.log(
|
|
` #${snap.id} ${snap.username}: ${rows.length} -> ${tree.length} rows ` +
|
|
`(+${tree.length - rows.length} synthesized), ${roots.length} root(s), ` +
|
|
`${roots.reduce((n, r) => n + r.subtreeFileCount, 0)} files, ${Date.now() - started}ms`,
|
|
);
|
|
}
|
|
|
|
process.exit(0);
|