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, + }); +}