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.
399 lines
16 KiB
TypeScript
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)));
|
|
}
|