import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from '../db'; import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from '../db'; import { authHeaders, getSlskdConfig } 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 }; }); } const SEP = '\\'; /** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */ function splitPath(name: string): { parentPath: string | null; label: string; depth: number } { const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name; const cut = trimmed.lastIndexOf(SEP); const depth = trimmed.split(SEP).filter(Boolean).length || 1; // `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is // neither null nor a real row, and the level query would never find it. return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth }; } /** * Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a * time instead of the platform recomputing the shape on every request. * * Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders) * exactly one intermediate folder was missing, so they're nearly always present, but a single gap would * make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder. * Rollups: a parent's own file count is usually just cover art, so the number worth showing on a * collapsed row is the subtree's, and that's summed bottom-up here rather than per request. */ export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] { const byName = new Map(); const ensure = (name: string): BrowseDirRow => { const existing = byName.get(name); if (existing) return existing; const { parentPath, label, depth } = splitPath(name); const row: BrowseDirRow = { name, parentPath, label, depth, childCount: 0, fileCount: 0, totalSize: 0, subtreeFileCount: 0, subtreeSize: 0, files: [], }; // Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow. byName.set(name, row); if (parentPath) ensure(parentPath); return row; }; for (const dir of dirs) { const row = ensure(dir.name); // Concatenated rather than assigned: a path repeated in the response would otherwise lose files. row.files = row.files.length ? row.files.concat(dir.files) : dir.files; row.fileCount = row.files.length; row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0); } const rows = [...byName.values()]; for (const row of rows) { row.subtreeFileCount = row.fileCount; row.subtreeSize = row.totalSize; } // Deepest first, so every child is final before its parent absorbs it. for (const row of [...rows].sort((a, b) => b.depth - a.depth)) { const parent = row.parentPath ? byName.get(row.parentPath) : undefined; if (!parent) continue; parent.childCount += 1; parent.subtreeFileCount += row.subtreeFileCount; parent.subtreeSize += row.subtreeSize; } return rows; } async function run(userId: number, username: string, snapshotId: number): Promise { const cfg = await getSlskdConfig(userId); if (!cfg) { await failSoulseekBrowse(snapshotId, 'slskd upstream not configured'); return; } const started = Date.now(); try { const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, { headers: authHeaders(cfg), 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); const tree = buildTree(dirs); await finishSoulseekBrowse(snapshotId, tree); const files = dirs.reduce((n, d) => n + d.files.length, 0); // Against distinct names, not the array length, so a repeated path doesn't read as a negative. const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size; console.log( `[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${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; }