diff --git a/public/slskd.png b/public/slskd.png
deleted file mode 100644
index 980e0078..00000000
Binary files a/public/slskd.png and /dev/null differ
diff --git a/scripts/rebuild-soulseek-tree.ts b/scripts/rebuild-soulseek-tree.ts
deleted file mode 100644
index db3e23b5..00000000
--- a/scripts/rebuild-soulseek-tree.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-// Re-derive the stored tree shape for every cached share snapshot, from the folder rows already in
-// Postgres — no peer contact, so it works with everyone offline and is deterministic.
-//
-// Written for the backfill when the tree columns were added, but kept because it's the answer to any
-// future change in tree shape: it runs the SAME buildTree + finishSoulseekBrowse the sidecar's ingest
-// runs, so there's no second implementation to drift.
-//
-// bun scripts/rebuild-soulseek-tree.ts
-
-import type { BrowsedFile } from 'officerdb';
-import { eq, asc } from 'drizzle-orm';
-import { db, finishSoulseekBrowse } from 'officerdb';
-// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator —
-// import them from the feature directly.
-import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema';
-import { buildTree } from '../src/servers/sidecar/slskd/browse';
-
-const snapshots = await db
- .select({
- id: soulseekBrowseSnapshots.id,
- userId: soulseekBrowseSnapshots.userId,
- username: soulseekBrowseSnapshots.username,
- status: soulseekBrowseSnapshots.status,
- })
- .from(soulseekBrowseSnapshots)
- .orderBy(asc(soulseekBrowseSnapshots.id));
-
-console.log(`${snapshots.length} snapshot(s) to rebuild`);
-
-for (const snap of snapshots) {
- const started = Date.now();
- const rows = await db
- .select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
- .from(soulseekBrowseDirs)
- .where(eq(soulseekBrowseDirs.snapshotId, snap.id));
-
- if (!rows.length) {
- console.log(` #${snap.id} ${snap.username}: no folder rows, skipped`);
- continue;
- }
-
- const tree = buildTree(rows.map((r) => ({ name: r.name, files: r.files as BrowsedFile[] })));
- await finishSoulseekBrowse(snap.id, tree);
- const roots = tree.filter((t) => t.parentPath === null);
- console.log(
- ` #${snap.id} ${snap.username}: ${rows.length} -> ${tree.length} rows ` +
- `(+${tree.length - rows.length} synthesized), ${roots.length} root(s), ` +
- `${roots.reduce((n, r) => n + r.subtreeFileCount, 0)} files, ${Date.now() - started}ms`,
- );
-}
-
-process.exit(0);
diff --git a/scripts/theme-baseline.json b/scripts/theme-baseline.json
index 62d1f9f4..1ff1946c 100644
--- a/scripts/theme-baseline.json
+++ b/scripts/theme-baseline.json
@@ -1,5 +1,5 @@
{
- "src/apps/officer-web/Screens": 144,
+ "src/apps/officer-web/Screens": 122,
"src/workspaces/components": 47,
"src/workspaces/officerdev/src/apps/AppStore": 6,
"src/workspaces/officerdev/src/apps/Chat": 12,
@@ -11,13 +11,9 @@
"src/workspaces/officerdev/src/apps/FileViewer": 38,
"src/workspaces/officerdev/src/apps/Invoices": 95,
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
- "src/workspaces/officerdev/src/apps/Photos": 47,
"src/workspaces/officerdev/src/apps/Preview": 4,
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
- "src/workspaces/officerdev/src/apps/Soulseek": 313,
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
"src/workspaces/officerdev/src/apps/Terminal": 10,
- "src/workspaces/officerdev/src/apps/Transmission": 43,
- "src/workspaces/officerdev/src/apps/Wallet": 68,
- "src/workspaces/officerdev/src/components": 22
+ "src/workspaces/officerdev/src/apps/Wallet": 68
}
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index bc822f41..611dd611 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -80,8 +80,6 @@ export function App() {
} />
} />
} />
- } />
- } />
} />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx
deleted file mode 100644
index dde79a1f..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Navigate, useParams } from 'react-router';
-import type { LayoutNode } from 'officerdev';
-import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev';
-import { useDashboardState } from 'state/useDashboardState';
-import { defaultLayout } from './defaultLayout';
-
-// /soulseek uses the Workspace/Panel system (like /command-hq): a section nav (soulseek-nav) on the left and
-// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection —
-// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
-
-export const SoulseekScreen = () => {
- const { section } = useParams();
- // v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
- // layout doesn't resurrect as two mismatched panels.
- const workspace = useDashboardState('screens/soulseek-v2', defaultLayout);
-
- // After the hooks — it returns early.
- if (!isSoulseekSection(section)) return ;
-
- return (
-
-
-
- );
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts
deleted file mode 100644
index e5f81710..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { LayoutNode } from 'officerdev';
-
-export const defaultLayout: LayoutNode = {
- type: 'group',
- id: 'soulseek-root',
- direction: 'horizontal',
- children: [
- { node: { type: 'panel', id: 'soulseek-nav', appType: 'soulseek-nav' }, size: 22 },
- { node: { type: 'panel', id: 'soulseek-view', appType: 'soulseek-view' }, size: 78 },
- ],
-};
diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx
deleted file mode 100644
index b6db6bc2..00000000
--- a/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from './SoulseekScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index 083549d1..aaa17b71 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -13,7 +13,6 @@ export * from './Tasks';
export * from './Files';
export * from './Calendar';
export * from './Contacts';
-export * from './Soulseek';
export * from './CommandHQ';
export * from './Jellyfin';
export * from './Gitea';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 8e7afd56..acebe7fc 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -24,7 +24,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
- { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts
index 8a2e7c5a..ecc908f1 100644
--- a/src/databases/officer_db/src/index.ts
+++ b/src/databases/officer_db/src/index.ts
@@ -49,7 +49,6 @@ export * from './dav';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './notify';
-export * from './soulseek';
export * from './wallet';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts
index b6318807..2f26f6b4 100644
--- a/src/databases/officer_db/src/schema.ts
+++ b/src/databases/officer_db/src/schema.ts
@@ -50,7 +50,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
-// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
diff --git a/src/databases/officer_db/src/soulseek/index.ts b/src/databases/officer_db/src/soulseek/index.ts
deleted file mode 100644
index 02ed187b..00000000
--- a/src/databases/officer_db/src/soulseek/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries';
-
-export {
- getSoulseekBrowseSnapshots,
- getSoulseekBrowseSnapshot,
- startSoulseekBrowse,
- finishSoulseekBrowse,
- failSoulseekBrowse,
- resetStaleSoulseekBrowses,
- getSoulseekBrowseLevel,
- searchSoulseekBrowseTree,
- getSoulseekBrowseDirFiles,
- getSoulseekBrowseDownload,
- deleteSoulseekBrowse,
-} from './queries';
-
-export type {
- BrowseDownloadFile,
- BrowsedFile,
- BrowseDirInput,
- BrowseDirRow,
- BrowseTreeNode,
- BrowseLevel,
- BrowseTreeSearch,
- SoulseekBrowseSnapshot,
-} from './queries';
diff --git a/src/databases/officer_db/src/soulseek/queries.ts b/src/databases/officer_db/src/soulseek/queries.ts
deleted file mode 100644
index a88090aa..00000000
--- a/src/databases/officer_db/src/soulseek/queries.ts
+++ /dev/null
@@ -1,398 +0,0 @@
-import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm';
-import { db } from '../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)));
-}
diff --git a/src/databases/officer_db/src/soulseek/schema.ts b/src/databases/officer_db/src/soulseek/schema.ts
deleted file mode 100644
index fd54bdf5..00000000
--- a/src/databases/officer_db/src/soulseek/schema.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
- pgTable,
- serial,
- integer,
- bigint,
- text,
- timestamp,
- index,
- jsonb,
- foreignKey,
- uniqueIndex,
-} from 'drizzle-orm/pg-core';
-import { users } from '../auth/schema';
-
-// Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API
-// (verified against 0.26.0's UsersController: only endpoint/browse/directory/info/status), so the peers
-// the owner marks live here instead of in a forked daemon.
-//
-// Every table here is `soulseek_`-prefixed, and this file deliberately holds nothing else: when sidecars
-// start owning their own schema, it moves wholesale into src/servers/sidecar/slskd/ with no untangling.
-// Only the officer-slskd sidecar reads or writes these tables.
-
-export const soulseekFavorites = pgTable(
- 'soulseek_favorites',
- {
- id: serial('id').primaryKey(),
- userId: integer('user_id')
- .notNull()
- .references(() => users.id, { onDelete: 'cascade' }),
- username: text('username').notNull(),
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
- },
- // Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix.
- (t) => [uniqueIndex('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) => [uniqueIndex('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.
-//
-// slskd hands back a flat list of backslash-delimited paths, so the tree is derived once at ingest and
-// stored here rather than recomputed per request: `parentPath` links a row to its parent and the subtree
-// rollups are precomputed, which is what lets the UI open one level at a time. Measured on real peers,
-// the tree is already complete in slskd's response — exactly one intermediate folder was missing across
-// 30k rows — but ingest synthesizes any that are absent so a level can never dead-end.
-export const soulseekBrowseDirs = pgTable(
- 'soulseek_browse_dirs',
- {
- id: serial('id').primaryKey(),
- snapshotId: integer('snapshot_id').notNull(),
- name: text('name').notNull(),
- // null for a root. Its parent's `name`, i.e. `name` minus the last backslash segment.
- parentPath: text('parent_path'),
- // Segment count, 1-based, so the UI can indent without walking back up the chain.
- depth: integer('depth').notNull().default(1),
- // Last path segment — what a tree row actually displays.
- label: text('label').notNull().default(''),
- childCount: integer('child_count').notNull().default(0),
- fileCount: integer('file_count').notNull().default(0),
- totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0),
- // This folder AND everything under it. A parent's own file count is usually cover art; the number
- // worth showing on a collapsed row is the whole subtree's.
- subtreeFileCount: integer('subtree_file_count').notNull().default(0),
- subtreeSize: bigint('subtree_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) => [
- // Filtered search and the name lookups behind ancestor expansion.
- index('idx_soulseek_browse_dirs_snapshot_name').on(t.snapshotId, t.name),
- // The tree's hot path: one level, ordered by name. Covers "the roots" too, via a null parent_path.
- index('idx_soulseek_browse_dirs_snapshot_parent').on(t.snapshotId, t.parentPath, 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'),
- ],
-);
diff --git a/src/servers/api/slskd/router.ts b/src/servers/api/slskd/router.ts
deleted file mode 100644
index f3c63d96..00000000
--- a/src/servers/api/slskd/router.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { createSidecarProxy } from '../../sidecar/create-proxy';
-
-// /api/slskd/* — auth, then forward to officer-slskd. No routes of its own and no slskd knowledge:
-// this file must never grow app logic.
-//
-// Covers both slskd's own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned
-// routes (/_officer/*, features slskd has no concept of). Both are opaque from here; the sidecar injects
-// the slskd API key. The full contract is documented at the top of src/servers/sidecar/slskd/index.ts.
-
-const proxy = createSidecarProxy({
- name: 'slskd',
- prefix: '/api/slskd',
-});
-
-export const slskdRouter = proxy.router;
-
-/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
-export const getSlskdServerUrl = proxy.getHttpUrl;
diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts
index 4a407856..2afaea31 100644
--- a/src/servers/app-store/catalogue.test.ts
+++ b/src/servers/app-store/catalogue.test.ts
@@ -107,10 +107,11 @@ describe('member provisioning is declared for every entry', () => {
});
it('never promises accounts for a single-tenant daemon', () => {
- // slskd has no user concept; claiming otherwise would make the installer try to create accounts
- // against an API that does not exist. (Transmission was the other example until 2026-08-18, when it
- // became `plugins/transmission/`.)
- for (const id of ['slskd']) expect(byId(id)!.members).toBe('none');
+ // A daemon with no user concept; claiming otherwise would make the installer try to create accounts
+ // against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until
+ // 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list
+ // rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing.
+ for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none');
});
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts
index 362a868a..2be1b7e9 100644
--- a/src/servers/app-store/catalogue.ts
+++ b/src/servers/app-store/catalogue.ts
@@ -226,27 +226,6 @@ export const CATALOGUE: CatalogueEntry[] = [
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
- {
- id: 'slskd',
- ui: {
- name: 'Soulseek',
- image: '/plugins/slskd/icon.png',
- color: '#ffffff',
- rootRoute: '/soulseek',
- routes: ['/soulseek'],
- },
- process: 'officer-slskd',
- label: 'Soulseek',
- summary: 'slskd — search and download from the Soulseek network',
- members: 'none',
- modes: ['existing', 'provisioned'],
- permission: 'soulseek',
- composeTemplate: 'slskd',
- existingFields: [
- { key: 'url', label: 'slskd URL', type: 'url', required: true },
- { key: 'secret', label: 'API key', type: 'secret', required: true },
- ],
- },
{
id: 'caldav',
ui: {
diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts
index 1d188011..400c6b64 100644
--- a/src/servers/app-store/installer.test.ts
+++ b/src/servers/app-store/installer.test.ts
@@ -7,7 +7,13 @@ import { byId } from './catalogue';
// when something goes wrong: resume, blocking, and not doing work twice.
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
-const slskd = byId('slskd')!; // existing|provisioned, members: 'none'
+// A LITERAL, not `byId(...)`. This was `byId('slskd')` until slskd became `plugins/soulseek/`, and
+// before that the same assertion had to be re-pointed when photos and transmission left. No catalogue
+// entry today is both `provisioned` and `members: 'none'`, so there is nothing left to borrow — and
+// borrowing was the mistake anyway: this pins `planSteps`, which is installer logic, and it should not
+// break because an unrelated service was extracted. Shaped from jellyfin so the rest of the entry stays
+// realistic.
+const noMemberConcept = { ...byId('jellyfin')!, id: 'test-single-tenant', members: 'none' as const };
const configOnly = byId('notify')!; // config only
/** Records what was actually called, so a test can assert on absence as well as presence. */
@@ -46,7 +52,7 @@ describe('planSteps', () => {
it('omits the members step for a service with no user concept', () => {
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
// silent failure to anyone debugging why a member has no access.
- expect(planSteps(slskd, 'provisioned')).not.toContain('members');
+ expect(planSteps(noMemberConcept, 'provisioned')).not.toContain('members');
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
});
diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts
index b17d3a45..009dc25f 100644
--- a/src/servers/app-store/members.test.ts
+++ b/src/servers/app-store/members.test.ts
@@ -12,13 +12,13 @@ describe('what adding a member would do', () => {
it('predicts the outcome per service without contacting anything', () => {
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
expect(plannedOutcome(inviteOnly)).toBe('invited');
- expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable');
+ expect(plannedOutcome(byId('vnc')!)).toBe('not-applicable');
});
});
describe('which installed services have work to do', () => {
it('skips single-tenant daemons entirely', () => {
- const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id);
+ const ids = servicesNeedingMemberProvision(['jellyfin', 'vnc', 'memos']).map((e) => e.id);
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
});
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 1dd789ea..47b53bb0 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router';
import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
-// import { slskdRouter } from './api/slskd/router';
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
// import { jellyfinRouter } from './api/jellyfin/router';
// import { walletRouter } from './api/wallet/router';
@@ -141,7 +140,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType
['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
- // ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
['/terminal', terminalRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts
index a78db3f3..4652269d 100644
--- a/src/servers/permissions/registry.ts
+++ b/src/servers/permissions/registry.ts
@@ -200,15 +200,6 @@ const CORE_REGISTRY: Permission[] = [
// Device registration is the caller's own — a phone subscribing to its own push channel.
personal: ['/devices', '/subscriptions'],
},
- {
- key: 'soulseek',
- label: 'Soulseek',
- description: 'Search and download from the Soulseek network',
- kind: 'app',
- // api: ['/slskd'], // plugin — switched off 2026-08-13
- api: [],
- routes: ['/soulseek'],
- },
{
key: 'invoices',
label: 'Invoices',
diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts
index 26130bc5..6edf66bb 100644
--- a/src/servers/plugins/marketplace.ts
+++ b/src/servers/plugins/marketplace.ts
@@ -149,6 +149,27 @@ const BUNDLED: CatalogueEntry[] = [
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
has: { api: true, schema: false, sidecar: true, web: true },
},
+ {
+ appName: 'soulseek',
+ publisher: 'officerdev',
+ version: '1.0.0',
+ platform: '>=1.0.0',
+ label: 'Soulseek',
+ summary: 'Search and download from the Soulseek network, via your own slskd daemon',
+ color: '#22c55e',
+ icon: 'Music2',
+ source: 'https://gitea.officer.dev/plugins/soulseek.git',
+ permissions: [
+ {
+ key: 'soulseek',
+ label: 'Soulseek',
+ description: 'Search and download from the Soulseek network',
+ },
+ ],
+ // `schema: true` unlike transmission: the CONNECTION lives in core `service_connections`, but the
+ // favourites and the cached browse tree are this plugin's own three tables.
+ has: { api: true, schema: true, sidecar: true, web: true },
+ },
{
appName: 'vault',
publisher: 'officerdev',
diff --git a/src/servers/sidecar/slskd/browse.ts b/src/servers/sidecar/slskd/browse.ts
deleted file mode 100644
index b14d63e7..00000000
--- a/src/servers/sidecar/slskd/browse.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
-import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
-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;
-}
diff --git a/src/servers/sidecar/slskd/config.ts b/src/servers/sidecar/slskd/config.ts
deleted file mode 100644
index ea8640b1..00000000
--- a/src/servers/sidecar/slskd/config.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import type { SlskdConfig } from './upstream';
-import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
-import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } from './upstream';
-
-// `/_config` — where the slskd daemon is and what key opens it, driven from the app rather than from a
-// shell on the server.
-//
-// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it
-// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send
-// a new one, which is the same shape the photos and offscale registries use.
-//
-// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the
-// path and no activate route — a save is an upsert over the single row, and the UI is one form.
-//
-// A save is validated against the live daemon before it is stored: a wrong URL or a rejected key is a 400
-// with the reason, not a saved row that makes every later panel fail mysteriously.
-
-export type ProbeResult =
- | { ok: true; version: string | null; server: string | null }
- | { ok: false; version: string | null; error: string };
-
-/**
- * Ask a daemon whether it is really there and whether the key works.
- *
- * Two calls, because they answer different questions: `/health` is unauthenticated, so a failure there means
- * the URL is wrong or slskd is down, while `/api/v0/application` failing after it succeeded means the key is
- * the problem. Collapsing them would report "daemon unreachable" for a mistyped key.
- */
-export async function probe(cfg: SlskdConfig): Promise {
- try {
- const health = await fetch(`${cfg.base}/health`, { signal: AbortSignal.timeout(5000), redirect: 'manual' });
- if (!health.ok) return { ok: false, version: null, error: `daemon returned ${health.status}` };
- } catch (err) {
- return { ok: false, version: null, error: `could not reach the daemon (${String(err)})` };
- }
-
- try {
- const res = await fetch(`${cfg.base}/api/v0/application`, {
- headers: { Accept: 'application/json', ...authHeaders(cfg) },
- signal: AbortSignal.timeout(5000),
- redirect: 'manual',
- });
- if (!res.ok) {
- const reason = res.status === 401 || res.status === 403 ? 'API key was rejected' : 'daemon returned';
- return { ok: false, version: null, error: `${reason} (${res.status})` };
- }
- const app = (await res.json()) as {
- version?: { current?: string; full?: string };
- server?: { state?: string };
- };
- return { ok: true, version: app.version?.full ?? app.version?.current ?? null, server: app.server?.state ?? null };
- } catch (err) {
- return { ok: false, version: null, error: String(err) };
- }
-}
-
-/** What the browser is allowed to know about the connection. Never includes the key. */
-async function connectionState(userId: number): Promise {
- const connection = await getServiceConnection(userId, 'slskd');
- return Response.json({ configured: !!connection, connection });
-}
-
-const bad = (error: string, status = 400) => Response.json({ error }, { status });
-
-type ConnectionBody = { url?: unknown; apiKey?: unknown };
-
-const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
-
-/** Save the connection: validated against the live daemon, then stored with the key encrypted. */
-async function save(req: Request, userId: number): Promise {
- const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
- const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
- // A blank key on an existing connection means "keep the stored one", so changing the URL does not need
- // the key re-typed. `undefined` is what saveServiceConnection reads as leave-alone.
- const apiKey = typeof body.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined;
-
- if (!url) return bad('url is required');
- if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
-
- const current = await getServiceConnection(userId, 'slskd');
- if (!apiKey && !current?.hasSecret) return bad('an API key is required');
-
- // Probing needs the actual key, so a URL-only save borrows the stored one through the normal read path
- // rather than reaching for the ciphertext here.
- const stored = apiKey ? null : await getSlskdConfig(userId);
- const result = await probe({ base: url, apiKey: apiKey ?? stored?.apiKey ?? null });
- if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
-
- const connection = await saveServiceConnection({
- userId,
- service: 'slskd',
- url,
- secret: apiKey,
- version: result.version,
- });
- invalidateSlskdConfig(userId);
- return Response.json({ connection, server: result.server });
-}
-
-/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
-export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise {
- if (subpath && subpath !== '/') return bad('not found', 404);
-
- if (req.method === 'GET') return connectionState(userId);
- if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
-
- if (req.method === 'DELETE') {
- await deleteServiceConnection(userId, 'slskd');
- invalidateSlskdConfig(userId);
- return connectionState(userId);
- }
-
- return bad('method not allowed', 405);
-}
diff --git a/src/servers/sidecar/slskd/download.ts b/src/servers/sidecar/slskd/download.ts
deleted file mode 100644
index 5dc67c62..00000000
--- a/src/servers/sidecar/slskd/download.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { getSoulseekBrowseDownload } from 'officerdb';
-import { authHeaders, getSlskdConfig } from './upstream';
-
-// Enqueueing downloads out of a cached share tree.
-//
-// slskd's own enqueue endpoint takes a flat list of {filename, size}, which is fine for search results —
-// they arrive as individual files with full paths. A browsed folder is neither: it's a path, and what the
-// user means by clicking it is "everything under here". Expanding that into a file list needs the cache,
-// so it happens HERE rather than in the browser, which would otherwise have to walk the tree one level
-// request at a time and reconstruct paths it only half knows.
-
-/**
- * A folder can be enormous — the root of one measured peer is 284,166 files. Enqueueing that would be a
- * misclick that takes the slskd instance down with it, so it's refused with the count rather than
- * truncated to a silent partial download.
- */
-export const MAX_ENQUEUE = 10_000;
-
-export class DownloadError extends Error {
- constructor(
- message: string,
- readonly status: number,
- ) {
- super(message);
- }
-}
-
-type EnqueueParams = { userId: number; username: string; path: string; file?: string };
-
-/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */
-export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise {
- const cfg = await getSlskdConfig(userId);
- if (!cfg) throw new DownloadError('slskd upstream not configured', 503);
-
- const files = await getSoulseekBrowseDownload({ userId, username, path, file });
- if (!files.length) throw new DownloadError('nothing to download at that path', 404);
- if (files.length > MAX_ENQUEUE) {
- throw new DownloadError(
- `${files.length.toLocaleString()} files is over the ${MAX_ENQUEUE.toLocaleString()} limit`,
- 413,
- );
- }
-
- const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', ...authHeaders(cfg) },
- body: JSON.stringify(files),
- });
- if (!res.ok) {
- const detail = (await res.text().catch(() => '')).slice(0, 200);
- throw new DownloadError(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`, 502);
- }
-
- console.log(`[slskd] queued ${files.length} file(s) from ${username}: ${file ?? path}`);
- return files.length;
-}
diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts
deleted file mode 100644
index 41596519..00000000
--- a/src/servers/sidecar/slskd/index.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import type { SidecarCommand, SidecarEvent } from '../protocol';
-import { createSidecarConnector } from '../connect';
-import { resetStaleSoulseekBrowses } from 'officerdb';
-import { getSlskdConfig, stripHopByHop } from './upstream';
-import { handleConfigRoute, probe } from './config';
-import { handleOfficerRoute } from './officer';
-import { API_URL } from '../../officer-url.mjs';
-
-// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
-// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd
-// (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a
-// thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow
-// end-to-end without the main server accreting any slskd-specific logic.
-//
-// ─────────────────────────────────────────────────────────────────────────────────────────────────
-// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive
-// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject
-// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
-// The server listens on a random loopback port, reported to the API on connect so it can route here.
-//
-// GET /_health ours (probes slskd's /health), not part of the slskd contract
-// GET /_config { configured, connection } — the URL and whether a key is stored, never the key
-// PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank
-// apiKey on an existing connection keeps the stored one
-// DEL /_config forget the connection
-//
-// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
-// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
-// here rather than in the platform API is what lets the main server stay a pure proxy forever.
-//
-// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
-// loopback only, so its presence is the trust signal — a request without it did not come through the
-// platform, and the connection is per-owner data.
-// ─────────────────────────────────────────────────────────────────────────────────────────────────
-//
-// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
-// /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy
-// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its
-// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP.
-
-/** Grab an ephemeral free port by briefly binding one and releasing it. */
-function getFreePort(): number {
- const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
- const p = probeServer.port;
- probeServer.stop(true);
- if (p == null) throw new Error('failed to acquire a free port');
- return p;
-}
-
-const port = getFreePort();
-
-const server = Bun.serve({
- port,
- hostname: '127.0.0.1',
- idleTimeout: 255, // browse/search can idle; Bun caps this at 255s
- maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses
- async fetch(req) {
- const url = new URL(req.url);
-
- const officerUser = req.headers.get('X-Officer-User');
- const userId = Number(officerUser);
- if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
- return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
- }
-
- if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
- try {
- return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
- } catch (err) {
- console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
- return Response.json({ error: 'internal error' }, { status: 500 });
- }
- }
-
- const cfg = await getSlskdConfig(userId);
-
- // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
- // configured-but-broken daemon is the whole reason the flag is on the response.
- if (url.pathname === '/_health') {
- if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
- const started = Date.now();
- const result = await probe(cfg);
- const ms = Date.now() - started;
- if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
- return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms });
- }
-
- // Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
- if (url.pathname.startsWith('/_officer/')) {
- try {
- const res = await handleOfficerRoute(req, url);
- if (res) return res;
- return new Response('not found', { status: 404 });
- } catch (err) {
- console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
- return Response.json({ error: 'internal error' }, { status: 500 });
- }
- }
-
- if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 });
-
- const target = `${cfg.base}${url.pathname}${url.search}`;
- const method = req.method;
- const hasBody = method !== 'GET' && method !== 'HEAD';
- const started = Date.now();
-
- const headers = stripHopByHop(req.headers);
- if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey);
-
- // Bun/undici require half-duplex to stream a request body straight through.
- const init: RequestInit & { duplex?: 'half' } = {
- method,
- headers,
- body: hasBody ? req.body : undefined,
- redirect: 'manual', // a transparent proxy passes 3xx through rather than following them
- };
- if (hasBody) init.duplex = 'half';
-
- let upstream: Response;
- try {
- upstream = await fetch(target, init);
- } catch {
- console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`);
- return new Response('slskd upstream unreachable', { status: 502 });
- }
-
- console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`);
- return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
- },
-});
-
-// No upstream in the banner: where slskd lives is now per-owner state read from the database per request,
-// not a constant this process knows at boot.
-console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`);
-
-// 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;
-
-function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
- switch (cmd.type) {
- case 'ping':
- reply({ type: 'pong', id: cmd.id });
- break;
- default:
- reply({
- type: 'error',
- id: (cmd as SidecarCommand).id,
- error: `Unknown command type: ${(cmd as Record).type}`,
- });
- }
-}
-
-// ── Connect to API server ──
-
-const connection = createSidecarConnector({
- apiUrl: `${API_URL}/api/sidecar/register`,
- name: 'slskd',
- handles: ['slskd'],
- onCommand(cmd, reply) {
- handleCommand(cmd as SidecarCommand, reply as ReplyFn);
- },
- onConnected() {
- // Tell the API where our proxy is listening, so it can forward /api/slskd/* here.
- connection.send({ type: 'slskd:server', port });
- console.log(`[slskd] reported proxy port ${port} to API`);
- },
-});
-
-// ── Graceful shutdown ──
-
-function shutdown(signal: string) {
- console.log(`[slskd] ${signal} received, shutting down...`);
- try {
- server.stop(true);
- } catch {
- /* already stopped */
- }
- connection.destroy();
- process.exit(0);
-}
-
-process.on('SIGTERM', () => shutdown('SIGTERM'));
-process.on('SIGINT', () => shutdown('SIGINT'));
diff --git a/src/servers/sidecar/slskd/officer.ts b/src/servers/sidecar/slskd/officer.ts
deleted file mode 100644
index e2626818..00000000
--- a/src/servers/sidecar/slskd/officer.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import {
- getSoulseekFavorites,
- addSoulseekFavorite,
- removeSoulseekFavorite,
- getSoulseekBrowseSnapshots,
- getSoulseekBrowseSnapshot,
- getSoulseekBrowseLevel,
- searchSoulseekBrowseTree,
- getSoulseekBrowseDirFiles,
- deleteSoulseekBrowse,
-} from 'officerdb';
-import { startBrowse } from './browse';
-import { enqueueFromCache, DownloadError } from './download';
-
-// 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
-// slskd stays unforked. They live under the `/_officer/` prefix, mirroring `/_health`, so they can never
-// collide with slskd's own surface (all of which is under `/api/v0/`).
-//
-// ─────────────────────────────────────────────────────────────────────────────────────────────────
-// CONTRACT (reached from the browser as /api/slskd/_officer/…)
-// GET /_officer/favorites → string[] favourited peers, alphabetical
-// POST /_officer/favorites { username } → { ok: true } add (idempotent)
-// DELETE /_officer/favorites?username= → { ok: true } remove (no-op if absent)
-//
-// GET /_officer/browse → Snapshot[] every cached share tree
-// GET /_officer/browse/ → Snapshot | null one peer's cache state
-// POST /_officer/browse/ → 202 { started } kick off a background fetch
-// DELETE /_officer/browse/ → { ok: true } drop the cache
-// GET /_officer/browse//tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent)
-// GET /_officer/browse//tree/search?q=&limit= → { nodes, matched, total, truncated }
-// GET /_officer/browse//dirs//files → BrowsedFile[] one folder's files
-// POST /_officer/browse//download { path, file? } → { queued } enqueue a folder, or one file
-// ─────────────────────────────────────────────────────────────────────────────────────────────────
-
-// The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We
-// bind loopback only, so it's trusted — same arrangement as the music sidecar's per-user state routes.
-const userIdOf = (req: Request): number | null => {
- const n = Number(req.headers.get('x-officer-user'));
- return Number.isInteger(n) && n > 0 ? n : null;
-};
-
-// Soulseek usernames are opaque to us and may contain spaces, so the only shapes we reject are ones
-// no nick can have: empty, absurdly long, or containing control characters.
-const USERNAME_MAX = 255;
-const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
-const cleanUsername = (v: unknown): string | null => {
- if (typeof v !== 'string') return null;
- const u = v.trim();
- if (!u || u.length > USERNAME_MAX) return null;
- return CONTROL_CHARS.test(u) ? null : u;
-};
-
-// A level, not a page: once you're inside a folder there's nothing to leaf through, so the UI asks for
-// the whole level when it's wide. The cap is set above the widest fan-out measured on a real peer (1,181).
-const DIR_PAGE_MAX = 2000;
-const DIR_PAGE_DEFAULT = 200;
-// A match cap, not a page: every match drags its ancestors along, so the response is up to ~depth times
-// this. Truncation is reported in the payload rather than passed off as the whole answer.
-const SEARCH_MATCH_MAX = 1000;
-const SEARCH_MATCH_DEFAULT = 300;
-const clampInt = (raw: string | null, fallback: number, min: number, max: number): number => {
- // An absent param has to be caught before Number(): `Number(null)` is 0, not NaN, so it passed the
- // finite check and clamped to `min` — which ran the tree search with LIMIT 1 and returned a single
- // match, making its siblings look like non-matches in the UI.
- if (raw === null || raw.trim() === '') return fallback;
- const n = Number(raw);
- if (!Number.isFinite(n)) return fallback;
- return Math.min(max, Math.max(min, Math.trunc(n)));
-};
-
-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 {
- 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 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 badRequest('username required');
- await removeSoulseekFavorite(userId, username);
- return Response.json({ ok: true });
- }
-
- return methodNotAllowed();
-}
-
-type BrowseRouteParams = { req: Request; url: URL; userId: number; segments: string[] };
-
-async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams): Promise {
- // /_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/
- 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();
- }
-
- // /_officer/browse//tree — one level of folders, paged. No `parent` means the roots.
- if (segments[2] === 'tree' && segments.length === 3) {
- if (req.method !== 'GET') return methodNotAllowed();
- const level = await getSoulseekBrowseLevel({
- userId,
- username,
- parent: url.searchParams.get('parent') ?? 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(level);
- }
-
- // /_officer/browse//tree/search — matches plus their ancestors, as one small tree. Kept off
- // the level route because the shape differs: this one is a skeleton spanning depths, not a page.
- if (segments[2] === 'tree' && segments.length === 4 && segments[3] === 'search') {
- if (req.method !== 'GET') return methodNotAllowed();
- const result = await searchSoulseekBrowseTree({
- userId,
- username,
- q: url.searchParams.get('q') ?? '',
- limit: clampInt(url.searchParams.get('limit'), SEARCH_MATCH_DEFAULT, 1, SEARCH_MATCH_MAX),
- });
- return Response.json(result);
- }
-
- // /_officer/browse//download — a folder's whole subtree, or one file inside it.
- if (segments[2] === 'download' && segments.length === 3) {
- if (req.method !== 'POST') return methodNotAllowed();
- const body = (await req.json().catch(() => null)) as { path?: unknown; file?: unknown } | null;
- const path = typeof body?.path === 'string' ? body.path.trim() : '';
- if (!path) return badRequest('path required');
- const file = typeof body?.file === 'string' && body.file.trim() ? body.file : undefined;
- try {
- const queued = await enqueueFromCache({ userId, username, path, file });
- return Response.json({ queued });
- } catch (err) {
- if (err instanceof DownloadError) return Response.json({ error: err.message }, { status: err.status });
- throw err;
- }
- }
-
- if (segments[2] !== 'dirs') return notFound();
-
- // /_officer/browse//dirs//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, username, 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 {
- 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;
-}
diff --git a/src/servers/sidecar/slskd/upstream.ts b/src/servers/sidecar/slskd/upstream.ts
deleted file mode 100644
index d42f47e5..00000000
--- a/src/servers/sidecar/slskd/upstream.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { getServiceCredentials } from 'officerdb';
-
-// slskd upstream config + header hygiene for the officer-slskd sidecar.
-//
-// All knowledge of the slskd instance — its URL and its API key — lives here. The platform API is a thin
-// auth+forward proxy and holds NO slskd credentials.
-//
-// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `service_connections` (see
-// databases/officer_db/src/service-connections/queries.ts). It is deliberately no longer read from the
-// environment: Bun auto-loads `.env` into every process started in the platform directory, so an
-// `SLSKD_API_KEY` there was also sitting in `officer`'s own process.env — a credential that drives the whole
-// Soulseek daemon, held by the one process with no code to use it. Nothing in this file reads process.env.
-
-/** Everything needed to reach the daemon. A candidate being validated has this and nothing else yet. */
-export type SlskdConfig = { base: string; apiKey: string | null };
-
-/** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */
-export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
-
-// The transfers view polls every couple of seconds and every request needs the key, so the row is cached
-// rather than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the
-// row in psql, which then takes effect within a minute instead of needing a restart.
-const TTL_MS = 60_000;
-const cache = new Map();
-
-/**
- * The owner's slskd connection, or null when nothing is configured — the sidecar then answers 503, and the
- * UI turns that into the setup form rather than a wall of empty panels.
- */
-export async function getSlskdConfig(userId: number): Promise {
- const hit = cache.get(userId);
- if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
-
- const creds = await getServiceCredentials(userId, 'slskd');
- // `url` became nullable when services that inherit one instance landed (see the schema column note).
- // slskd fronts a single daemon and always stores its own, so null here is a malformed row, not an
- // inheriting one — treat it as unconfigured rather than reaching for somebody else's URL.
- const cfg = creds?.url ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
- cache.set(userId, { cfg, at: Date.now() });
- return cfg;
-}
-
-/** Drop the cached row — called by the config routes after any save or removal. */
-export function invalidateSlskdConfig(userId: number): void {
- cache.delete(userId);
-}
-
-/** The auth header slskd expects, ready to spread. Empty when the daemon has no key configured. */
-export const authHeaders = (cfg: SlskdConfig): Record =>
- cfg.apiKey ? { 'X-API-Key': cfg.apiKey } : {};
-
-// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `host` is dropped so the outgoing
-// fetch sets the upstream authority itself; everything else — including our injected X-API-Key — passes.
-const HOP_BY_HOP = new Set([
- 'connection',
- 'keep-alive',
- 'proxy-authenticate',
- 'proxy-authorization',
- 'te',
- 'trailer',
- 'transfer-encoding',
- 'upgrade',
- 'host',
-]);
-
-/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */
-export function stripHopByHop(src: Headers): Headers {
- const out = new Headers();
- src.forEach((value, key) => {
- const k = key.toLowerCase();
- if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
- out.set(key, value);
- });
- return out;
-}
diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
index 9721ef52..319c094f 100644
--- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
+++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
@@ -10,7 +10,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
-import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
@@ -33,7 +32,6 @@ export const apps = [
...chatHistoryMetas,
...widgetMetas,
...desktopMetas,
- ...soulseekMetas,
...jellyfinMetas,
...giteaMetas,
...invoicesMetas,
diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx
index a02fcc91..eb0eb709 100644
--- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx
+++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx
@@ -120,7 +120,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
) : isGenerating ? (
) : (
-
+
)}
{isConnected && onDisconnect && (