soulseek: cache peer share trees server-side

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 23:59:45 +00:00
co-authored by Claude Opus 4.8
parent df3643612c
commit 1ac5bffb6c
11 changed files with 919 additions and 103 deletions
+12
View File
@@ -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,
@@ -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<string[]> {
@@ -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<SoulseekBrowseSnapshot[]> {
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<SoulseekBrowseSnapshot | null> {
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<number> {
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<void> {
const rows = dirs.map((d) => ({
snapshotId,
name: d.name,
fileCount: d.files.length,
totalSize: d.files.reduce((n, f) => n + (f.size || 0), 0),
files: d.files,
}));
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<void> {
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<number> {
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<BrowseDirPage> {
const { userId, username, q, limit, offset } = params;
const [snap] = await db
.select({ id: soulseekBrowseSnapshots.id })
.from(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
.limit(1);
if (!snap) return { dirs: [], total: 0 };
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<number>`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<BrowsedFile[] | null> {
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<void> {
await db
.delete(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)));
}
@@ -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'),
],
);
+104
View File
@@ -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<string, Promise<void>>();
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<void> {
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<boolean> {
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;
}
+7
View File
@@ -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;
+106 -13
View File
@@ -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=<u> → { 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=<u> → { ok: true } remove (no-op if absent)
//
// GET /_officer/browse → Snapshot[] every cached share tree
// GET /_officer/browse/<u> → Snapshot | null one peer's cache state
// POST /_officer/browse/<u> → 202 { started } kick off a background fetch
// DELETE /_officer/browse/<u> → { ok: true } drop the cache
// GET /_officer/browse/<u>/dirs?q=&limit=&offset= → { dirs, total } a page of folders, no files
// GET /_officer/browse/<u>/dirs/<id>/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<Response | null> {
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<Response> {
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<Response> {
// /_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/<username>
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/<username>/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/<username>/dirs/<id>/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<Response | null> {
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;
}
@@ -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<T>(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 <span className="shrink-0 text-xs text-zinc-600">not cached</span>;
if (snapshot.status === 'pending')
return (
<span className="flex shrink-0 items-center gap-1 text-xs text-primary">
<Loader2 className="h-3 w-3 animate-spin" />
fetching
</span>
);
if (snapshot.status === 'failed' && !snapshot.directoryCount)
return (
<span className="flex shrink-0 items-center gap-1 text-xs text-red-400" title={snapshot.error ?? undefined}>
<TriangleAlert className="h-3 w-3" />
failed
</span>
);
return (
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
{snapshot.directoryCount.toLocaleString()} folders
</span>
);
};
export const SharesBrowser = ({ username }: { username: string }) => {
const { snapshotOf, fetchShares, dropShares } = useSoulseekBrowseSnapshots();
const [filter, setFilter] = useState('');
const [page, setPage] = useState(0);
const [openDir, setOpenDir] = useState<number | null>(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 (
<div className="rounded-xl border border-white/10 bg-zinc-950">
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 px-4 py-2.5">
<FolderOpen className="h-4 w-4 shrink-0 text-zinc-400" />
<span className="text-sm font-medium text-zinc-100">Shared folders</span>
{snapshot && !!snapshot.directoryCount && (
<span className="text-xs tabular-nums text-zinc-500">
· {snapshot.directoryCount.toLocaleString()} folders · {snapshot.fileCount.toLocaleString()} files ·{' '}
{formatSize(snapshot.totalSize)}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => fetchShares(username)}
disabled={pending}
title={hasCache ? 'Fetch again in the background' : 'Fetch this share tree in the background'}
className="flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-zinc-300 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-50"
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : hasCache ? (
<RefreshCw className="h-3.5 w-3.5" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{pending ? 'Fetching…' : hasCache ? 'Refresh' : 'Fetch shares'}
</button>
{hasCache && (
<button
type="button"
onClick={() => dropShares(username)}
title="Drop this cached share tree"
className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-500 transition hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Status banners — a fetch runs server-side, so leaving the panel is safe. */}
{pending && (
<div className="flex items-center gap-2 border-b border-white/10 bg-primary/5 px-4 py-2 text-xs text-zinc-300">
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
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.
</div>
)}
{snapshot?.status === 'failed' && (
<div className="flex items-start gap-2 border-b border-white/10 bg-red-500/5 px-4 py-2 text-xs text-red-300">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Last fetch failed{snapshot.error ? `: ${snapshot.error}` : ''}.{' '}
{hasCache && 'Showing the previously cached tree.'}
</span>
</div>
)}
{!hasCache && !pending ? (
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
<Folder className="h-8 w-8 text-zinc-700" />
<p className="max-w-sm text-sm text-zinc-500">
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.
</p>
</div>
) : (
hasCache && (
<>
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<input
value={filter}
onChange={(ev) => 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 && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-zinc-500" />}
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{total.toLocaleString()} matching</span>
</div>
{total === 0 ? (
<p className="px-4 py-6 text-center text-sm text-zinc-500">No folders match {q}.</p>
) : (
<div className="divide-y divide-white/5">
{data?.dirs.map((dir) => (
<div key={dir.id}>
<button
type="button"
onClick={() => setOpenDir((cur) => (cur === dir.id ? null : dir.id))}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors hover:bg-white/[0.03]"
>
{openDir === dir.id ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
)}
<span className="min-w-0 flex-1 truncate text-zinc-200" title={dir.name}>
{dir.name}
</span>
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
{dir.fileCount} file{dir.fileCount === 1 ? '' : 's'} · {formatSize(dir.totalSize)}
</span>
</button>
{openDir === dir.id && <DirFiles username={username} dirId={dir.id} />}
</div>
))}
</div>
)}
{pages > 1 && (
<div className="flex items-center justify-between border-t border-white/10 px-4 py-2 text-xs text-zinc-500">
<span className="tabular-nums">
{(page * PAGE_SIZE + 1).toLocaleString()}{Math.min(total, (page + 1) * PAGE_SIZE).toLocaleString()}{' '}
of {total.toLocaleString()}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-md px-2 py-1 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
Previous
</button>
<span className="tabular-nums">
{page + 1} / {pages.toLocaleString()}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1}
className="rounded-md px-2 py-1 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
Next
</button>
</div>
</div>
)}
{snapshot?.completedAt && (
<div className="border-t border-white/5 px-4 py-1.5 text-right text-xs text-zinc-600">
cached {formatWhen(snapshot.completedAt)}
</div>
)}
</>
)
)}
</div>
);
};
type DirFilesProps = { username: string; dirId: number };
const DirFiles = ({ username, dirId }: DirFilesProps) => {
const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId);
if (isPending)
return (
<div className="flex items-center gap-2 bg-black/20 px-4 py-2 pl-10 text-xs text-zinc-500">
<Loader2 className="h-3 w-3 animate-spin" /> Loading files
</div>
);
if (error) return <p className="bg-black/20 px-4 py-2 pl-10 text-xs text-red-400">Could not load this folder.</p>;
if (!data?.length) return <p className="bg-black/20 px-4 py-2 pl-10 text-xs text-zinc-500">Empty folder.</p>;
return (
<div className="max-h-72 overflow-y-auto bg-black/20">
{data.map((file) => (
<div key={file.name} className="flex items-center gap-2 px-4 py-1.5 pl-10 text-xs">
<FileAudio className="h-3 w-3 shrink-0 text-zinc-600" />
<span className="min-w-0 flex-1 truncate text-zinc-300" title={file.name}>
{file.name}
</span>
<span className="shrink-0 tabular-nums text-zinc-500">{formatSize(file.size)}</span>
</div>
))}
</div>
);
};
@@ -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 <SharesBrowser> — 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<Loaded | null>(null);
const [dirs, setDirs] = useState<SlskdBrowseDirectory[] | null>(null);
const [browsing, setBrowsing] = useState(false);
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(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<SlskdBrowseResponse | SlskdBrowseDirectory[]>(
`/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<SlskdUserStatus>(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`),
client.get<SlskdUserInfo>(`/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}
</button>
<BrowseStateChip snapshot={snapshotOf(u)} />
{!snapshotOf(u) && (
<button
type="button"
onClick={() => fetchShares(u)}
title={`Fetch ${u}'s shares in the background`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-500 transition hover:bg-white/10 hover:text-zinc-200"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={() => toggle(u)}
@@ -232,47 +220,7 @@ export const SoulseekUsers = () => {
)}
</div>
{/* Shares */}
<div className="rounded-xl border border-white/10 bg-zinc-950">
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
<FolderOpen className="h-4 w-4 text-zinc-400" />
<span className="text-sm font-medium text-zinc-100">Shared folders</span>
{dirs && <span className="text-xs text-zinc-500">· {dirs.length}</span>}
{!dirs && (
<button
type="button"
onClick={() => browse(peer.username)}
disabled={browsing}
className="ml-auto flex items-center gap-1.5 text-xs text-zinc-400 transition hover:text-zinc-100 disabled:opacity-40"
>
{browsing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FolderOpen className="h-3.5 w-3.5" />
)}
{browsing ? 'Browsing' : 'Browse shares'}
</button>
)}
</div>
{dirs &&
(dirs.length === 0 ? (
<p className="px-4 py-3 text-sm text-zinc-500">No shared folders.</p>
) : (
<div className="max-h-96 divide-y divide-white/5 overflow-y-auto">
{dirs.map((d) => (
<div key={d.name} className="flex items-center gap-2 px-4 py-2 text-sm">
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<span className="min-w-0 flex-1 truncate text-zinc-200" title={d.name}>
{d.name}
</span>
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
{d.fileCount} file{d.fileCount === 1 ? '' : 's'}
</span>
</div>
))}
</div>
))}
</div>
<SharesBrowser username={peer.username} />
</>
)}
</div>
@@ -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) => {
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={browse}>
<DropdownMenuItem onSelect={openPeer}>
<FolderOpen className="mr-2 h-4 w-4" />
Browse user files
View profile &amp; shares
</DropdownMenuItem>
<DropdownMenuItem onSelect={toggleFavorite}>
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
@@ -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 = {
@@ -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<SoulseekBrowseSnapshot[]>('/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<SoulseekBrowseSnapshot[]>(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<SoulseekBrowseSnapshot[]>(
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<SoulseekBrowseDirPage>(`/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<SoulseekBrowsedFile[]>(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`),
enabled: !!username && dirId !== null,
staleTime: Infinity,
});
}