Files
soulseek/db/queries.ts
T
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

399 lines
16 KiB
TypeScript

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<string[]> {
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<void> {
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<void> {
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<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 built tree: replace the folder rows and mark the snapshot ready, atomically. */
export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise<void> {
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<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;
}
/** 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<number | null> {
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<BrowseLevel> {
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<number>`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<BrowseTreeSearch> {
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<number>`count(*)::int` })
.from(soulseekBrowseDirs)
.where(where),
]);
const have = new Set(matches.map((m) => m.name));
const wanted = new Set<string>();
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<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),
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<BrowseDownloadFile[]> {
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<void> {
await db
.delete(soulseekBrowseSnapshots)
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)));
}