import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm'; import { db } from 'officerdb/db'; 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 { const rows = await db .select({ username: soulseekFavorites.username }) .from(soulseekFavorites) .where(eq(soulseekFavorites.userId, userId)) .orderBy(asc(soulseekFavorites.username)); return rows.map((r) => r.username); } /** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */ export async function addSoulseekFavorite(userId: number, username: string): Promise { await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing(); } /** Remove a favourite (no-op if it wasn't set). */ export async function removeSoulseekFavorite(userId: number, username: string): Promise { await db .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 as slskd gives it: a full backslash-delimited path, plus its files. */ export type BrowseDirInput = { name: string; files: BrowsedFile[] }; /** * One folder as it's stored — a tree node. slskd's flat paths are linked up into this shape by the * sidecar's `buildTree` before they get here, which is why every count is already on the row. */ export type BrowseDirRow = { name: string; parentPath: string | null; depth: number; label: string; childCount: number; fileCount: number; totalSize: number; subtreeFileCount: number; subtreeSize: number; 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 built tree: replace the folder rows and mark the snapshot ready, atomically. */ export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise { const rows = tree.map((r) => ({ ...r, snapshotId })); 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)); } // Summed over the roots, not over every row: the subtree rollups already include descendants, so // adding them all up would count each file once per level above it. const roots = rows.filter((r) => r.parentPath === null); await tx .update(soulseekBrowseSnapshots) .set({ status: 'ready', error: null, completedAt: new Date(), directoryCount: rows.length, fileCount: roots.reduce((n, r) => n + r.subtreeFileCount, 0), totalSize: roots.reduce((n, r) => n + r.subtreeSize, 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; } /** A tree row as the UI renders it. `files` stays out — those load per folder, on expand. */ export type BrowseTreeNode = { id: number; name: string; parentPath: string | null; label: string; depth: number; childCount: number; fileCount: number; totalSize: number; subtreeFileCount: number; subtreeSize: number; }; const treeCols = { id: soulseekBrowseDirs.id, name: soulseekBrowseDirs.name, parentPath: soulseekBrowseDirs.parentPath, label: soulseekBrowseDirs.label, depth: soulseekBrowseDirs.depth, childCount: soulseekBrowseDirs.childCount, fileCount: soulseekBrowseDirs.fileCount, totalSize: soulseekBrowseDirs.totalSize, subtreeFileCount: soulseekBrowseDirs.subtreeFileCount, subtreeSize: soulseekBrowseDirs.subtreeSize, }; /** The snapshot a request is scoped to, or null if this peer was never fetched. */ async function snapshotIdOf(userId: number, username: string): Promise { const [snap] = await db .select({ id: soulseekBrowseSnapshots.id }) .from(soulseekBrowseSnapshots) .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username))) .limit(1); return snap?.id ?? null; } export type BrowseLevel = { nodes: BrowseTreeNode[]; total: number }; type BrowseLevelParams = { userId: number; username: string; parent?: string; limit: number; offset: number }; /** * One level of the tree: the children of `parent`, or the roots when it's absent. Paged, because fan-out * is not gentle — the widest folder measured on a real peer has 1,181 children. */ export async function getSoulseekBrowseLevel(params: BrowseLevelParams): Promise { const { userId, username, parent, limit, offset } = params; const snapshotId = await snapshotIdOf(userId, username); if (snapshotId === null) return { nodes: [], total: 0 }; const where = and( eq(soulseekBrowseDirs.snapshotId, snapshotId), parent ? eq(soulseekBrowseDirs.parentPath, parent) : isNull(soulseekBrowseDirs.parentPath), ); const [nodes, [count]] = await Promise.all([ db .select(treeCols) .from(soulseekBrowseDirs) .where(where) .orderBy(asc(soulseekBrowseDirs.name)) .limit(limit) .offset(offset), db .select({ total: sql`count(*)::int` }) .from(soulseekBrowseDirs) .where(where), ]); return { nodes, total: count?.total ?? 0 }; } // These names come off strangers' filesystems, so they're littered with typographic punctuation that no // keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'. // Typing "Hell's Ditch" or "1984-1985" found nothing at all, so both sides get folded to ASCII first. // Same character set on both sides — extend one and you must extend the other. const FOLD_FROM = '‘’ʼ'“”‐‑‒–—―− '; const FOLD_TO = "''''\"\"------- "; // Not in the translate() pair because it's one character standing in for three. const ELLIPSIS = '…'; const foldPunctuation = (value: string): string => { let out = ''; for (const ch of value) { const at = FOLD_FROM.indexOf(ch); out += at === -1 ? ch : FOLD_TO[at]; } return out.split(ELLIPSIS).join('...'); }; // Postgres reads '%', '_' and '\' as LIKE metacharacters, so unescaped input meant a typed '_' matched // every folder in the share while a pasted path — and every name here IS a backslash-delimited path — // silently matched none. Backslash stays the escape character, which is why it's escaped first. const likePattern = (term: string): string => `%${foldPunctuation(term).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`; /** The column, folded the same way `likePattern` folds the term. */ const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`; export type BrowseTreeSearch = { /** Matches AND every ancestor of a match, so the UI has a whole tree to render, not orphans. */ nodes: BrowseTreeNode[]; /** `name`s of the rows that actually matched — the rest of `nodes` are there to hold them up. */ matched: string[]; /** Matches in the snapshot, which exceeds `matched.length` when the cap kicked in. */ total: number; truncated: boolean; }; type BrowseTreeSearchParams = { userId: number; username: string; q: string; limit: number }; const SEP = '\\'; /** Every path above `name`, longest first: 'a\b\c' → ['a\b', 'a']. */ function ancestorsOf(name: string): string[] { const out: string[] = []; for (let cut = name.lastIndexOf(SEP); cut > 0; cut = name.lastIndexOf(SEP, cut - 1)) { out.push(name.slice(0, cut)); } return out; } /** * Filtered view of the tree. Filtering never flattens it: matches come back with their full ancestor * chains, which is what lets the UI open straight down to a hit and keep the shape visible around it. * * Ancestors are resolved by string prefix rather than a recursive CTE — the parent's path IS a prefix of * the child's, so one indexed `name in (…)` lookup does what a walk would take `depth` round trips to do. */ export async function searchSoulseekBrowseTree(params: BrowseTreeSearchParams): Promise { const { userId, username, q, limit } = params; const snapshotId = await snapshotIdOf(userId, username); const term = q.trim(); if (snapshotId === null || !term) return { nodes: [], matched: [], total: 0, truncated: false }; // Words are AND-ed, each matched anywhere in the path, so 'pogues hell' finds // '…\The Pogues\… - Hell's Ditch …' — a folder you can name from two ends but never type in full. // ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's // ~18k rows — which is why folding the column, once per word, costs nothing on top. const words = term.split(/\s+/).filter(Boolean); const where = and( eq(soulseekBrowseDirs.snapshotId, snapshotId), ...words.map((word) => sql`${foldedName} ilike ${likePattern(word)}`), ); const [matches, [count]] = await Promise.all([ db.select(treeCols).from(soulseekBrowseDirs).where(where).orderBy(asc(soulseekBrowseDirs.name)).limit(limit), db .select({ total: sql`count(*)::int` }) .from(soulseekBrowseDirs) .where(where), ]); const have = new Set(matches.map((m) => m.name)); const wanted = new Set(); for (const m of matches) for (const a of ancestorsOf(m.name)) if (!have.has(a)) wanted.add(a); const ancestors = wanted.size ? await db .select(treeCols) .from(soulseekBrowseDirs) .where(and(eq(soulseekBrowseDirs.snapshotId, snapshotId), inArray(soulseekBrowseDirs.name, [...wanted]))) : []; const nodes = [...matches, ...ancestors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); const total = count?.total ?? 0; return { nodes, matched: matches.map((m) => m.name), total, truncated: total > matches.length }; } /** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */ type DirFilesParams = { userId: number; username: string; dirId: number }; // Joined and scoped by BOTH owner and peer: a dir id alone would happily return another peer's folder // (ids are global), so the username in the request URL has to be part of the predicate, not decoration. export async function getSoulseekBrowseDirFiles({ userId, username, dirId, }: DirFilesParams): 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), eq(soulseekBrowseSnapshots.username, username), ), ) .limit(1); return row ? (row.files as BrowsedFile[]) : null; } /** A file as slskd wants it enqueued: the peer's own full path, plus the size it advertised. */ export type BrowseDownloadFile = { filename: string; size: number }; type BrowseDownloadParams = { userId: number; username: string; path: string; file?: string }; /** * Resolve a download request to the exact files to enqueue — one folder's subtree, or a single file. * * Sizes and paths come from the cache rather than the client: slskd matches a queued download on * filename AND size, so a wrong number is a transfer that never starts. The stored file name is a * basename (browse, unlike search, doesn't repeat the folder), so the peer's real path is rejoined here. */ export async function getSoulseekBrowseDownload({ userId, username, path, file, }: BrowseDownloadParams): Promise { const snapshotId = await snapshotIdOf(userId, username); if (snapshotId === null) return []; const rows = await db .select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files }) .from(soulseekBrowseDirs) .where( and( eq(soulseekBrowseDirs.snapshotId, snapshotId), // starts_with, not LIKE: these paths are full of backslashes, and one would be a LIKE escape. file ? eq(soulseekBrowseDirs.name, path) : sql`(${soulseekBrowseDirs.name} = ${path} or starts_with(${soulseekBrowseDirs.name}, ${path + SEP}))`, ), ) .orderBy(asc(soulseekBrowseDirs.name)); return rows.flatMap((row) => (row.files as BrowsedFile[]) .filter((f) => (file ? f.name === file : true)) .map((f) => ({ filename: `${row.name}${SEP}${f.name}`, size: f.size })), ); } /** 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))); }