soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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';
|
||||
@@ -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<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)));
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user