From 1ac5bffb6c77e4a8dded3d89ae0b9ec7655db7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 23:59:45 +0000 Subject: [PATCH] soulseek: cache peer share trees server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsing a peer inline could never work. slskd answers GET /users/{u}/browse with the entire tree in one blocking response — measured at 59 MB / 18k folders / 284k files for a single real peer — and it takes minutes because it round-trips to that peer. The browser was made to wait for that, so navigating away threw the whole thing out and the panel showed an error more often than a tree. So the fetch moves into the sidecar and the result into Postgres. Clicking "fetch shares" returns 202 and the job keeps running without the tab; the UI polls the snapshot row and reads back pages. Folders are rows and files ride along as jsonb on their folder, because folders are what you filter and page through while files are only ever read for the one folder you opened — a row per file would be 284k rows per peer for no gain. A failed or in-flight refresh deliberately leaves the previous folders in place: a peer going offline shouldn't cost you a good cache, so the panel drives off rows existing rather than off status. Interrupted 'pending' snapshots are failed at sidecar boot, since the job died with the process and would otherwise spin forever. Co-Authored-By: Claude Opus 4.8 --- src/databases/officer_db/src/index.ts | 12 + .../officer_db/src/queries/soulseek.ts | 189 ++++++++++++- .../officer_db/src/schema/soulseek.ts | 73 ++++- src/servers/sidecar/slskd/browse.ts | 104 +++++++ src/servers/sidecar/slskd/index.ts | 7 + src/servers/sidecar/slskd/officer.ts | 119 +++++++- .../src/apps/Soulseek/SharesBrowser.tsx | 267 ++++++++++++++++++ .../src/apps/Soulseek/SoulseekUsers.tsx | 106 ++----- .../officerdev/src/apps/Soulseek/UserMenu.tsx | 12 +- .../officerdev/src/apps/Soulseek/shared.ts | 27 +- .../src/apps/Soulseek/useSoulseekBrowse.ts | 106 +++++++ 11 files changed, 919 insertions(+), 103 deletions(-) create mode 100644 src/servers/sidecar/slskd/browse.ts create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 281b6f94..ef80c24f 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -103,6 +103,18 @@ export type { Playlist, } from './queries/music'; export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries/soulseek'; +export { + getSoulseekBrowseSnapshots, + getSoulseekBrowseSnapshot, + startSoulseekBrowse, + finishSoulseekBrowse, + failSoulseekBrowse, + resetStaleSoulseekBrowses, + getSoulseekBrowseDirs, + getSoulseekBrowseDirFiles, + deleteSoulseekBrowse, +} from './queries/soulseek'; +export type { BrowsedFile, BrowseDirInput, BrowseDirPage, SoulseekBrowseSnapshot } from './queries/soulseek'; export { getVaultTokens, setVaultTokens, diff --git a/src/databases/officer_db/src/queries/soulseek.ts b/src/databases/officer_db/src/queries/soulseek.ts index 0170d50b..b85f6ebd 100644 --- a/src/databases/officer_db/src/queries/soulseek.ts +++ b/src/databases/officer_db/src/queries/soulseek.ts @@ -1,6 +1,6 @@ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, ilike, sql } from 'drizzle-orm'; import { db } from '../db'; -import { soulseekFavorites } from '../schema'; +import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from '../schema'; /** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */ export async function getSoulseekFavorites(userId: number): Promise { @@ -23,3 +23,188 @@ export async function removeSoulseekFavorite(userId: number, username: string): .delete(soulseekFavorites) .where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username))); } + +// ── Cached share trees ── + +/** 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. */ +export type BrowseDirInput = { name: string; files: BrowsedFile[] }; + +export type SoulseekBrowseSnapshot = { + username: string; + status: string; + error: string | null; + directoryCount: number; + fileCount: number; + totalSize: number; + startedAt: Date; + completedAt: Date | null; +}; + +const snapshotCols = { + username: soulseekBrowseSnapshots.username, + status: soulseekBrowseSnapshots.status, + error: soulseekBrowseSnapshots.error, + directoryCount: soulseekBrowseSnapshots.directoryCount, + fileCount: soulseekBrowseSnapshots.fileCount, + totalSize: soulseekBrowseSnapshots.totalSize, + startedAt: soulseekBrowseSnapshots.startedAt, + completedAt: soulseekBrowseSnapshots.completedAt, +}; + +/** Every cached share tree for a user, so the UI can badge each favourite in one request. */ +export async function getSoulseekBrowseSnapshots(userId: number): Promise { + return db + .select(snapshotCols) + .from(soulseekBrowseSnapshots) + .where(eq(soulseekBrowseSnapshots.userId, userId)) + .orderBy(asc(soulseekBrowseSnapshots.username)); +} + +/** One peer's snapshot, or null if never fetched. */ +export async function getSoulseekBrowseSnapshot( + userId: number, + username: string, +): Promise { + const [row] = await db + .select(snapshotCols) + .from(soulseekBrowseSnapshots) + .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username))) + .limit(1); + return row ?? null; +} + +/** + * Mark a peer's tree as being fetched and return the snapshot id to ingest into. Deliberately leaves the + * existing folder rows and counts alone: they stay readable while the refresh runs, and survive it failing. + */ +export async function startSoulseekBrowse(userId: number, username: string): Promise { + const [row] = await db + .insert(soulseekBrowseSnapshots) + .values({ userId, username, status: 'pending' }) + .onConflictDoUpdate({ + target: [soulseekBrowseSnapshots.userId, soulseekBrowseSnapshots.username], + set: { status: 'pending', error: null, startedAt: new Date(), completedAt: null }, + }) + .returning({ id: soulseekBrowseSnapshots.id }); + if (!row) throw new Error('failed to create browse snapshot'); + return row.id; +} + +// Each row carries a whole folder's file list, so keep batches small enough that no single INSERT gets +// 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 { + 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, + })); + 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)); + } + await tx + .update(soulseekBrowseSnapshots) + .set({ + status: 'ready', + 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), + }) + .where(eq(soulseekBrowseSnapshots.id, snapshotId)); + }); +} + +/** Record a failed fetch. Counts and folder rows are left as they were — a stale cache beats none. */ +export async function failSoulseekBrowse(snapshotId: number, error: string): Promise { + await db + .update(soulseekBrowseSnapshots) + .set({ status: 'failed', error: error.slice(0, 1000), completedAt: new Date() }) + .where(eq(soulseekBrowseSnapshots.id, snapshotId)); +} + +/** + * Fail every snapshot still marked pending. Called on sidecar boot: an in-flight fetch dies with the + * process, so without this a crash mid-browse leaves a row spinning in the UI forever. + */ +export async function resetStaleSoulseekBrowses(): Promise { + const rows = await db + .update(soulseekBrowseSnapshots) + .set({ status: 'failed', error: 'interrupted by a sidecar restart', completedAt: new Date() }) + .where(eq(soulseekBrowseSnapshots.status, 'pending')) + .returning({ id: soulseekBrowseSnapshots.id }); + return rows.length; +} + +export type BrowseDirPage = { + dirs: { id: number; name: string; fileCount: number; totalSize: number }[]; + total: number; +}; +type BrowseDirPageParams = { userId: number; username: string; q?: string; limit: number; offset: number }; + +/** + * 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. + */ +export async function getSoulseekBrowseDirs(params: BrowseDirPageParams): Promise { + 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 }; + + const term = q?.trim(); + // ILIKE with a leading wildcard can't use the btree index, but 18k rows per snapshot is a trivial scan. + const where = term + ? and(eq(soulseekBrowseDirs.snapshotId, snap.id), ilike(soulseekBrowseDirs.name, `%${term}%`)) + : eq(soulseekBrowseDirs.snapshotId, snap.id); + + 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), + db + .select({ total: sql`count(*)::int` }) + .from(soulseekBrowseDirs) + .where(where), + ]); + return { dirs, total: count?.total ?? 0 }; +} + +/** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */ +export async function getSoulseekBrowseDirFiles(userId: number, dirId: number): Promise { + const [row] = await db + .select({ files: soulseekBrowseDirs.files }) + .from(soulseekBrowseDirs) + .innerJoin(soulseekBrowseSnapshots, eq(soulseekBrowseDirs.snapshotId, soulseekBrowseSnapshots.id)) + .where(and(eq(soulseekBrowseDirs.id, dirId), eq(soulseekBrowseSnapshots.userId, userId))) + .limit(1); + return row ? (row.files as BrowsedFile[]) : null; +} + +/** Drop a peer's cache entirely (snapshot + folders, via the FK cascade). */ +export async function deleteSoulseekBrowse(userId: number, username: string): Promise { + await db + .delete(soulseekBrowseSnapshots) + .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username))); +} diff --git a/src/databases/officer_db/src/schema/soulseek.ts b/src/databases/officer_db/src/schema/soulseek.ts index 79a4dc10..2e6e081c 100644 --- a/src/databases/officer_db/src/schema/soulseek.ts +++ b/src/databases/officer_db/src/schema/soulseek.ts @@ -1,4 +1,15 @@ -import { pgTable, serial, integer, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { + pgTable, + serial, + integer, + bigint, + text, + timestamp, + unique, + index, + jsonb, + foreignKey, +} from 'drizzle-orm/pg-core'; import { users } from './auth'; // Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API @@ -22,3 +33,63 @@ export const soulseekFavorites = pgTable( // Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix. (t) => [unique('uq_soulseek_favorites_user_username').on(t.userId, t.username)], ); + +// ── Cached share trees ── +// +// A peer's share tree comes from slskd as ONE blocking response with every file of every folder inlined: +// measured at 59 MB / 18k folders / 284k files for a single real peer. Sending that to a browser is +// hopeless, and the browse is lost the moment the tab navigates away. So the sidecar fetches it in the +// background and caches it here, and the UI reads paginated slices instead. +// +// One snapshot row per (user, peer) — refreshes replace it rather than accumulating history. + +export const soulseekBrowseSnapshots = pgTable( + 'soulseek_browse_snapshots', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + username: text('username').notNull(), + // 'pending' while the sidecar is fetching, then 'ready' or 'failed'. A failed refresh keeps the + // previous rows, so a peer going offline never destroys a good cache. + status: text('status').notNull().default('pending'), + error: text('error'), + directoryCount: integer('directory_count').notNull().default(0), + fileCount: integer('file_count').notNull().default(0), + // Shares run to terabytes, well past int4. + totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0), + startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp('completed_at', { withTimezone: true }), + }, + (t) => [unique('uq_soulseek_browse_snapshots_user_username').on(t.userId, t.username)], +); + +// 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. +export const soulseekBrowseDirs = pgTable( + 'soulseek_browse_dirs', + { + id: serial('id').primaryKey(), + snapshotId: integer('snapshot_id').notNull(), + name: text('name').notNull(), + fileCount: integer('file_count').notNull().default(0), + totalSize: bigint('total_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". + index('idx_soulseek_browse_dirs_snapshot_name').on(t.snapshotId, 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({ + name: 'soulseek_browse_dirs_snapshot_id_fk', + columns: [t.snapshotId], + foreignColumns: [soulseekBrowseSnapshots.id], + }).onDelete('cascade'), + ], +); diff --git a/src/servers/sidecar/slskd/browse.ts b/src/servers/sidecar/slskd/browse.ts new file mode 100644 index 00000000..6b7504c5 --- /dev/null +++ b/src/servers/sidecar/slskd/browse.ts @@ -0,0 +1,104 @@ +import type { BrowseDirInput, BrowsedFile } from 'officerdb'; +import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb'; +import { getSlskdBase, getSlskdApiKey } from './upstream'; + +// Background share-tree fetches. +// +// slskd's GET /users/{u}/browse is a single blocking call that inlines every file of every folder — +// measured at 59 MB / 18k folders / 284k files for one real peer, and it can take minutes because it +// round-trips to that peer over the Soulseek network. Doing it from the browser means a tab that hangs +// and throws the result away on navigation. +// +// So it runs HERE instead: the route kicks this off and returns immediately, the fetch outlives the +// request (and the tab), and the result lands in Postgres for the UI to page through. The sidecar is a +// long-lived PM2 process, which is what makes "come back later and it's there" work at all. + +// slskd's wire shape. Files carry `attributes` (bitrate/duration) that real peers leave empty, plus +// `code`/`attributeCount` noise; all of it is dropped at ingest. +type SlskdBrowseFile = { filename: string; size?: number; extension?: string }; +type SlskdBrowseDir = { name: string; fileCount?: number; files?: SlskdBrowseFile[] }; +type SlskdBrowsePayload = { directories?: SlskdBrowseDir[] } | SlskdBrowseDir[]; + +// One browse per (user, peer) at a time. A second click while one is running is a no-op rather than a +// duplicate multi-minute fetch of the same tree. +const inFlight = new Map>(); +const keyOf = (userId: number, username: string) => `${userId}:${username}`; + +/** Peers currently being fetched, so a caller can report "already running" without racing the map. */ +export const isBrowseRunning = (userId: number, username: string): boolean => inFlight.has(keyOf(userId, username)); + +// Generous, but not unbounded: a peer that accepts the connection and then stalls shouldn't pin a +// snapshot in 'pending' forever. +const BROWSE_TIMEOUT_MS = 10 * 60 * 1000; + +const extensionOf = (file: SlskdBrowseFile): string => { + const raw = file.extension?.trim(); + if (raw) return raw.replace(/^\./, '').toLowerCase(); + const dot = file.filename.lastIndexOf('.'); + return dot > 0 ? file.filename.slice(dot + 1).toLowerCase() : ''; +}; + +/** + * Strip the response to what the UI actually renders. Locked directories are skipped: they're shares the + * peer won't serve us, so listing them would only offer downloads that always fail. + */ +function slim(payload: SlskdBrowsePayload): BrowseDirInput[] { + const dirs = Array.isArray(payload) ? payload : (payload?.directories ?? []); + return dirs.map((dir) => { + const files: BrowsedFile[] = (dir.files ?? []).map((f) => ({ + // Browse gives a basename only, unlike search results' full remote path. Downloading one means + // rejoining it to the directory name with a backslash. + name: f.filename, + size: f.size ?? 0, + extension: extensionOf(f), + })); + return { name: dir.name, files }; + }); +} + +async function run(userId: number, username: string, snapshotId: number): Promise { + const base = getSlskdBase(); + if (!base) { + await failSoulseekBrowse(snapshotId, 'slskd upstream not configured'); + return; + } + const started = Date.now(); + try { + const headers = new Headers(); + const apiKey = getSlskdApiKey(); + if (apiKey) headers.set('X-API-Key', apiKey); + + const res = await fetch(`${base}/api/v0/users/${encodeURIComponent(username)}/browse`, { + headers, + signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS), + }); + if (!res.ok) { + const detail = (await res.text().catch(() => '')).slice(0, 200); + throw new Error(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`); + } + + const dirs = slim((await res.json()) as SlskdBrowsePayload); + await finishSoulseekBrowse(snapshotId, dirs); + 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`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`); + await failSoulseekBrowse(snapshotId, message); + } +} + +/** + * Start caching a peer's share tree and return once it's *registered*, not once it's done — the caller + * responds 202 and the fetch continues in the background. Resolves false if one was already running. + */ +export async function startBrowse(userId: number, username: string): Promise { + const key = keyOf(userId, username); + if (inFlight.has(key)) return false; + + const snapshotId = await startSoulseekBrowse(userId, username); + // Registered before awaiting anything inside run(), so two rapid clicks can't both get past the check. + const task = run(userId, username, snapshotId).finally(() => inFlight.delete(key)); + inFlight.set(key, task); + return true; +} diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts index 2c0e063d..f16bd24a 100644 --- a/src/servers/sidecar/slskd/index.ts +++ b/src/servers/sidecar/slskd/index.ts @@ -1,5 +1,6 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; +import { resetStaleSoulseekBrowses } from 'officerdb'; import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream'; import { handleOfficerRoute } from './officer'; @@ -107,6 +108,12 @@ const server = Bun.serve({ console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port} -> ${getSlskdBase() ?? '(SLSKD_URL unset)'}`); +// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life +// would spin in the UI forever. Clear it once, at boot, before serving. +resetStaleSoulseekBrowses() + .then((n) => n && console.log(`[slskd] failed ${n} browse snapshot(s) interrupted by restart`)) + .catch((err) => console.error('[slskd] could not reset stale browse snapshots', err)); + // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; diff --git a/src/servers/sidecar/slskd/officer.ts b/src/servers/sidecar/slskd/officer.ts index cd173491..831ca6d9 100644 --- a/src/servers/sidecar/slskd/officer.ts +++ b/src/servers/sidecar/slskd/officer.ts @@ -1,4 +1,14 @@ -import { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from 'officerdb'; +import { + getSoulseekFavorites, + addSoulseekFavorite, + removeSoulseekFavorite, + getSoulseekBrowseSnapshots, + getSoulseekBrowseSnapshot, + getSoulseekBrowseDirs, + getSoulseekBrowseDirFiles, + deleteSoulseekBrowse, +} from 'officerdb'; +import { startBrowse } from './browse'; // 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 @@ -7,9 +17,16 @@ import { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } fro // // ───────────────────────────────────────────────────────────────────────────────────────────────── // CONTRACT (reached from the browser as /api/slskd/_officer/…) -// GET /_officer/favorites → string[] favourited peer usernames, alphabetical -// POST /_officer/favorites { username } → { ok: true } add (idempotent) -// DELETE /_officer/favorites?username= → { ok: true } remove (no-op if absent) +// GET /_officer/favorites → string[] favourited peers, alphabetical +// POST /_officer/favorites { username } → { ok: true } add (idempotent) +// DELETE /_officer/favorites?username= → { ok: true } remove (no-op if absent) +// +// GET /_officer/browse → Snapshot[] every cached share tree +// GET /_officer/browse/ → Snapshot | null one peer's cache state +// POST /_officer/browse/ → 202 { started } kick off a background fetch +// DELETE /_officer/browse/ → { ok: true } drop the cache +// GET /_officer/browse//dirs?q=&limit=&offset= → { dirs, total } a page of folders, no files +// GET /_officer/browse//dirs//files → BrowsedFile[] one folder's files // ───────────────────────────────────────────────────────────────────────────────────────────────── // The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We @@ -30,30 +47,106 @@ const cleanUsername = (v: unknown): string | null => { return CONTROL_CHARS.test(u) ? null : u; }; -/** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */ -export async function handleOfficerRoute(req: Request, url: URL): Promise { - const path = url.pathname; - if (path !== '/_officer/favorites') return null; +const DIR_PAGE_MAX = 500; +const DIR_PAGE_DEFAULT = 100; +const clampInt = (raw: string | null, fallback: number, min: number, max: number): number => { + const n = Number(raw); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(n))); +}; - const userId = userIdOf(req); - if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); +const badRequest = (error: string) => Response.json({ error }, { status: 400 }); +const notFound = () => new Response('not found', { status: 404 }); +const methodNotAllowed = () => new Response('method not allowed', { status: 405 }); +async function handleFavorites(req: Request, url: URL, userId: number): Promise { if (req.method === 'GET') return Response.json(await getSoulseekFavorites(userId)); if (req.method === 'POST') { const body = (await req.json().catch(() => null)) as { username?: unknown } | null; const username = cleanUsername(body?.username); - if (!username) return Response.json({ error: 'username required' }, { status: 400 }); + if (!username) return badRequest('username required'); await addSoulseekFavorite(userId, username); return Response.json({ ok: true }); } if (req.method === 'DELETE') { const username = cleanUsername(url.searchParams.get('username')); - if (!username) return Response.json({ error: 'username required' }, { status: 400 }); + if (!username) return badRequest('username required'); await removeSoulseekFavorite(userId, username); return Response.json({ ok: true }); } - return new Response('method not allowed', { status: 405 }); + return methodNotAllowed(); +} + +type BrowseRouteParams = { req: Request; url: URL; userId: number; segments: string[] }; + +async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams): Promise { + // /_officer/browse — every cached tree, for badging the favourites list in one request. + if (segments.length === 1) { + if (req.method !== 'GET') return methodNotAllowed(); + return Response.json(await getSoulseekBrowseSnapshots(userId)); + } + + const username = cleanUsername(decodeURIComponent(segments[1] ?? '')); + if (!username) return badRequest('username required'); + + // /_officer/browse/ + if (segments.length === 2) { + if (req.method === 'GET') return Response.json(await getSoulseekBrowseSnapshot(userId, username)); + if (req.method === 'POST') { + // 202: the fetch takes minutes and outlives this request. Poll the GET for status. + const started = await startBrowse(userId, username); + return Response.json({ started }, { status: 202 }); + } + if (req.method === 'DELETE') { + await deleteSoulseekBrowse(userId, username); + return Response.json({ ok: true }); + } + return methodNotAllowed(); + } + + if (segments[2] !== 'dirs') return notFound(); + + // /_officer/browse//dirs — a filtered page of folders, files excluded. + if (segments.length === 3) { + if (req.method !== 'GET') return methodNotAllowed(); + const page = await getSoulseekBrowseDirs({ + userId, + username, + q: url.searchParams.get('q') ?? 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); + } + + // /_officer/browse//dirs//files + if (segments.length === 5 && segments[4] === 'files') { + if (req.method !== 'GET') return methodNotAllowed(); + const dirId = Number(segments[3]); + if (!Number.isInteger(dirId) || dirId <= 0) return badRequest('invalid directory id'); + const files = await getSoulseekBrowseDirFiles(userId, dirId); + return files ? Response.json(files) : notFound(); + } + + return notFound(); +} + +/** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */ +export async function handleOfficerRoute(req: Request, url: URL): Promise { + const segments = url.pathname + .replace(/^\/_officer\//, '') + .split('/') + .filter(Boolean); + if (!segments.length) return null; + + const userId = userIdOf(req); + if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); + + if (segments[0] === 'favorites' && segments.length === 1) return handleFavorites(req, url, userId); + if (segments[0] === 'browse') return handleBrowse({ req, url, userId, segments }); + + return null; } diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx new file mode 100644 index 00000000..4092ab1a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx @@ -0,0 +1,267 @@ +import { useState, useEffect } from 'react'; +import { + FolderOpen, + Folder, + Loader2, + Search, + RefreshCw, + Download, + ChevronRight, + ChevronDown, + TriangleAlert, + Trash2, + FileAudio, +} from 'lucide-react'; +import { useSoulseekBrowseSnapshots, useSoulseekBrowseDirs, useSoulseekBrowseFiles } from './useSoulseekBrowse'; +import { formatSize, formatWhen, type SoulseekBrowseSnapshot } from './shared'; + +// A peer's shared folders, read from Officer's cache rather than browsed live. +// +// The live slskd call returns the entire tree in one blocking response (59 MB / 18k folders / 284k files +// for a real peer) and takes minutes, because it round-trips to that peer. So nothing here triggers it +// inline: you ask the sidecar to fetch, it keeps going without the tab, and this reads back pages. + +const PAGE_SIZE = 100; + +/** Debounce the filter box so a keystroke doesn't become a request. */ +function useDebounced(value: T, ms: number): T { + const [held, setHeld] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setHeld(value), ms); + return () => clearTimeout(timer); + }, [value, ms]); + return held; +} + +/** Cache state for one peer, compact enough for a favourites row. */ +export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot | null }) => { + if (!snapshot) return not cached; + if (snapshot.status === 'pending') + return ( + + + fetching + + ); + if (snapshot.status === 'failed' && !snapshot.directoryCount) + return ( + + + failed + + ); + return ( + + {snapshot.directoryCount.toLocaleString()} folders + + ); +}; + +export const SharesBrowser = ({ username }: { username: string }) => { + const { snapshotOf, fetchShares, dropShares } = useSoulseekBrowseSnapshots(); + const [filter, setFilter] = useState(''); + const [page, setPage] = useState(0); + const [openDir, setOpenDir] = useState(null); + const q = useDebounced(filter.trim(), 250); + + const snapshot = snapshotOf(username); + // Folder rows survive a failed or in-flight refresh, so drive the list off the data existing rather + // than off the status — a stale tree is still worth browsing while a new one is being fetched. + const hasCache = !!snapshot && snapshot.directoryCount > 0; + + const { data, isFetching } = useSoulseekBrowseDirs({ username, q, page, pageSize: PAGE_SIZE, enabled: hasCache }); + + // A new peer or a new filter invalidates the current page number and any expanded folder. + useEffect(() => { + setPage(0); + setOpenDir(null); + }, [username, q]); + useEffect(() => { + setFilter(''); + }, [username]); + + const total = data?.total ?? 0; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const pending = snapshot?.status === 'pending'; + + return ( +
+
+ + Shared folders + {snapshot && !!snapshot.directoryCount && ( + + · {snapshot.directoryCount.toLocaleString()} folders · {snapshot.fileCount.toLocaleString()} files ·{' '} + {formatSize(snapshot.totalSize)} + + )} +
+ + {hasCache && ( + + )} +
+
+ + {/* Status banners — a fetch runs server-side, so leaving the panel is safe. */} + {pending && ( +
+ + Fetching this peer's shares in the background — it can take a few minutes for a large share, and it keeps + going if you navigate away. +
+ )} + {snapshot?.status === 'failed' && ( +
+ + + Last fetch failed{snapshot.error ? `: ${snapshot.error}` : ''}.{' '} + {hasCache && 'Showing the previously cached tree.'} + +
+ )} + + {!hasCache && !pending ? ( +
+ +

+ No cached shares for this peer yet. Fetching runs on the server and survives you closing the tab — come back + and it'll be here. +

+
+ ) : ( + hasCache && ( + <> +
+ + setFilter(ev.target.value)} + placeholder="Filter folders…" + className="h-7 min-w-0 flex-1 bg-transparent text-sm text-zinc-100 placeholder:text-zinc-600 outline-none" + /> + {isFetching && } + {total.toLocaleString()} matching +
+ + {total === 0 ? ( +

No folders match “{q}”.

+ ) : ( +
+ {data?.dirs.map((dir) => ( +
+ + {openDir === dir.id && } +
+ ))} +
+ )} + + {pages > 1 && ( +
+ + {(page * PAGE_SIZE + 1).toLocaleString()}–{Math.min(total, (page + 1) * PAGE_SIZE).toLocaleString()}{' '} + of {total.toLocaleString()} + +
+ + + {page + 1} / {pages.toLocaleString()} + + +
+
+ )} + + {snapshot?.completedAt && ( +
+ cached {formatWhen(snapshot.completedAt)} +
+ )} + + ) + )} +
+ ); +}; + +type DirFilesProps = { username: string; dirId: number }; + +const DirFiles = ({ username, dirId }: DirFilesProps) => { + const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId); + + if (isPending) + return ( +
+ Loading files… +
+ ); + if (error) return

Could not load this folder.

; + if (!data?.length) return

Empty folder.

; + + return ( +
+ {data.map((file) => ( +
+ + + {file.name} + + {formatSize(file.size)} +
+ ))} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx index d404b510..31664158 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx @@ -2,25 +2,20 @@ import { useState, useEffect } from 'react'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { toast } from 'sonner'; -import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2, Star, X } from 'lucide-react'; +import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react'; import { useSoulseekFavorites } from './useSoulseekFavorites'; -import { - SOULSEEK_USER_CHANNEL, - type SlskdBrowseDirectory, - type SlskdBrowseResponse, - type SlskdUserInfo, - type SlskdUserStatus, - type SoulseekUserRequest, -} from './shared'; +import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse'; +import { SharesBrowser, BrowseStateChip } from './SharesBrowser'; +import { SOULSEEK_USER_CHANNEL, type SlskdUserInfo, type SlskdUserStatus, type SoulseekUserRequest } from './shared'; -// Users panel — look up a peer: their presence (online/away/offline), profile info (description, upload -// slots, queue), and optionally browse their shared folders. GET /users/{u}/status + /info fetch the -// header; GET /users/{u}/browse pulls the share tree (a flat directory list). +// Users panel — look up a peer: their presence (online/away/offline) and profile info (description, +// upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come +// from Officer's own cache instead, via — see that file for why it isn't browsed live. // // This is also where the workspace's peer actions land: the username dropdown in search results and -// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up and -// auto-browse. Favourites — Officer's own data, since slskd has no such concept — get their own section -// at the top, which also serves as this panel's landing content before any lookup. +// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up. +// Favourites — Officer's own data, since slskd has no such concept — get their own section at the top, +// which also serves as this panel's landing content before any lookup. type Loaded = { username: string; @@ -44,34 +39,16 @@ export const SoulseekUsers = () => { const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const [peer, setPeer] = useState(null); - const [dirs, setDirs] = useState(null); - const [browsing, setBrowsing] = useState(false); const [request, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); const { favorites, isFavorite, toggle } = useSoulseekFavorites(); + const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots(); - const browse = async (username: string) => { - setBrowsing(true); - try { - const res = await client.get( - `/slskd/api/v0/users/${encodeURIComponent(username)}/browse`, - ); - // Tolerate both shapes: 0.26.0's source returns a bare array, the running build wraps it. - const tree = Array.isArray(res) ? res : (res?.directories ?? []); - setDirs([...tree].sort((a, b) => a.name.localeCompare(b.name))); - } catch (err) { - toast.error(`Browse failed: ${err instanceof Error ? err.message : String(err)}`); - } finally { - setBrowsing(false); - } - }; - - const lookup = async (name: string, autoBrowse = false) => { + const lookup = async (name: string) => { const username = name.trim(); if (!username || loading) return; setQuery(username); setLoading(true); setPeer(null); - setDirs(null); const [status, info] = await Promise.allSettled([ client.get(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`), client.get(`/slskd/api/v0/users/${encodeURIComponent(username)}/info`), @@ -86,15 +63,15 @@ export const SoulseekUsers = () => { status: status.status === 'fulfilled' ? status.value : null, info: info.status === 'fulfilled' ? info.value : null, }); - if (autoBrowse) browse(username); }; // Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and - // back doesn't re-run the (expensive) browse. + // back doesn't re-run the lookup. Shares are NOT fetched automatically — that's a multi-minute + // server-side job, so it stays an explicit click in the shares card. useEffect(() => { if (!request) return; setRequest(null); - lookup(request.username, request.browse); + lookup(request.username); // lookup is recreated each render; the effect deliberately runs only when a request arrives. // eslint-disable-next-line react-hooks/exhaustive-deps }, [request]); @@ -166,6 +143,17 @@ export const SoulseekUsers = () => { > {u} + + {!snapshotOf(u) && ( + + )} - )} - - {dirs && - (dirs.length === 0 ? ( -

No shared folders.

- ) : ( -
- {dirs.map((d) => ( -
- - - {d.name} - - - {d.fileCount} file{d.fileCount === 1 ? '' : 's'} - -
- ))} -
- ))} - + )} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx index 7e86076b..7939cd71 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx @@ -18,8 +18,8 @@ import { } from './shared'; // The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace -// and act on that peer. "Browse user files" hands the username to the Users section over a panel channel -// (which owns the lookup + browse); favouriting goes to the sidecar's Officer-owned favourites route. +// and act on that peer. Opening a peer hands the username to the Users section over a panel channel +// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route. type UserMenuProps = { username: string }; @@ -29,8 +29,8 @@ export const UserMenu = ({ username }: UserMenuProps) => { const { isFavorite, toggle } = useSoulseekFavorites(); const favorited = isFavorite(username); - const browse = () => { - setRequest({ username, browse: true, nonce: Date.now() }); + const openPeer = () => { + setRequest({ username, nonce: Date.now() }); setSection('users'); }; @@ -54,9 +54,9 @@ export const UserMenu = ({ username }: UserMenuProps) => { {username} - + - Browse user files + View profile & shares {favorited ? : } diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index 79a07b08..a4742e37 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -185,6 +185,29 @@ export type SlskdBrowseResponse = { lockedDirectoryCount: number; }; +// ── Cached share trees (Officer's own, from the sidecar's /_officer/browse routes) ── +// +// A live browse is one blocking slskd call carrying every file of every folder — 59 MB / 18k folders / +// 284k files for a real peer, thrown away the moment you navigate. So the sidecar fetches it in the +// background into Postgres and the UI reads paged slices. Timestamps arrive as ISO strings. + +export type SoulseekBrowseStatus = 'pending' | 'ready' | 'failed'; +export type SoulseekBrowseSnapshot = { + username: string; + status: SoulseekBrowseStatus; + error: string | null; + directoryCount: number; + fileCount: number; + totalSize: number; + startedAt: string; + completedAt: string | null; +}; +export type SoulseekBrowseDir = { id: number; name: string; fileCount: number; totalSize: number }; +export type SoulseekBrowseDirPage = { dirs: SoulseekBrowseDir[]; total: number }; +// Browse carries no bitrate/duration (real peers send empty `attributes`), so unlike a search result +// there's no quality metadata to show — just name, size, extension. +export type SoulseekBrowsedFile = { name: string; size: number; extension: string }; + // Server connection state (GET /server, and the server block of GET /application). export type SlskdServerState = { address?: string; state?: string; isConnected?: boolean; username?: string }; @@ -225,9 +248,9 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [ // Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users // section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't -// re-run an expensive browse, while two requests for the SAME username still each trigger a fresh lookup. +// re-run the lookup, while two requests for the SAME username still each trigger a fresh one. export const SOULSEEK_USER_CHANNEL = 'soulseek:user'; -export type SoulseekUserRequest = { username: string; browse: boolean; nonce: number }; +export type SoulseekUserRequest = { username: string; nonce: number }; // A past search, as listed by GET /searches (no responses inlined). export type SlskdSearchSummary = { diff --git a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts new file mode 100644 index 00000000..38e8f11c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts @@ -0,0 +1,106 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import type { SoulseekBrowseSnapshot, SoulseekBrowseDirPage, SoulseekBrowsedFile } from './shared'; + +// Client for the sidecar's cached share trees. The expensive work happens server-side and outlives the +// tab, so this hook only ever starts a fetch and polls for its status — it never pulls a whole tree. + +const SNAPSHOTS_KEY = ['soulseek', 'browse'] as const; +const EMPTY: SoulseekBrowseSnapshot[] = []; + +// While something is fetching we need to notice it finishing; otherwise this data barely changes. +const POLL_MS = 3000; + +/** + * Every peer's cache state in one request, so the favourites list can badge each row without N queries. + * Polls only while at least one fetch is in flight. + */ +export function useSoulseekBrowseSnapshots() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + + const { data } = useQuery({ + queryKey: SNAPSHOTS_KEY, + queryFn: () => get('/slskd/_officer/browse'), + staleTime: 10_000, + refetchInterval: (query) => (query.state.data?.some((s) => s.status === 'pending') ? POLL_MS : false), + }); + + const snapshots = data ?? EMPTY; + const snapshotOf = (username: string) => snapshots.find((s) => s.username === username) ?? null; + + const start = useMutation({ + mutationFn: (username: string) => post(`/slskd/_officer/browse/${encodeURIComponent(username)}`, {}), + // Flip to pending immediately so the row shows a spinner before the first poll lands. + onMutate: (username) => { + const prev = qc.getQueryData(SNAPSHOTS_KEY) ?? EMPTY; + const existing = prev.find((s) => s.username === username); + const pending: SoulseekBrowseSnapshot = { + ...(existing ?? { + username, + directoryCount: 0, + fileCount: 0, + totalSize: 0, + completedAt: null, + }), + status: 'pending', + error: null, + startedAt: new Date().toISOString(), + }; + qc.setQueryData( + SNAPSHOTS_KEY, + existing ? prev.map((s) => (s.username === username ? pending : s)) : [...prev, pending], + ); + return { prev }; + }, + onError: (_err, _username, ctx) => { + if (ctx?.prev) qc.setQueryData(SNAPSHOTS_KEY, ctx.prev); + }, + onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }), + }); + + const drop = useMutation({ + mutationFn: (username: string) => del(`/slskd/_officer/browse/${encodeURIComponent(username)}`), + onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }), + }); + + return { + snapshots, + snapshotOf, + fetchShares: (username: string) => start.mutate(username), + dropShares: (username: string) => drop.mutate(username), + }; +} + +type DirsParams = { username: string | null; q: string; page: number; pageSize: number; enabled?: boolean }; + +/** One page of a peer's cached folders. Keeps the previous page visible while the next loads. */ +export function useSoulseekBrowseDirs({ username, q, page, pageSize, enabled = true }: DirsParams) { + const { get } = useClient(); + const offset = page * pageSize; + + return useQuery({ + queryKey: ['soulseek', 'browse', username, 'dirs', q, offset, pageSize], + queryFn: () => { + const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (q) params.set('q', q); + return get(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs?${params}`); + }, + enabled: enabled && !!username, + placeholderData: (prev) => prev, + staleTime: 30_000, + }); +} + +/** 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(); + + return useQuery({ + queryKey: ['soulseek', 'browse', username, 'dir', dirId], + queryFn: () => + get(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`), + enabled: !!username && dirId !== null, + staleTime: Infinity, + }); +}