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.
115 lines
5.6 KiB
TypeScript
115 lines
5.6 KiB
TypeScript
import {
|
|
pgTable,
|
|
serial,
|
|
integer,
|
|
bigint,
|
|
text,
|
|
timestamp,
|
|
index,
|
|
jsonb,
|
|
foreignKey,
|
|
uniqueIndex,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { users } from 'officerdb/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'),
|
|
],
|
|
);
|