a browsed folder is a path, not a file list, and what clicking one means is "everything under here" — so the expansion happens in the sidecar, off the cache, rather than making the browser walk the tree a level at a time and rebuild paths it only half knows. browse reports file names as basenames, unlike search, so the peer's real path is rejoined from the folder row. sizes come from the cache too: slskd matches a queued download on filename AND size, so a number supplied by the client would be a transfer that silently never starts. a subtree can be the peer's whole share (284k files on one measured peer), so an over-limit request is refused with its count rather than truncated into a partial download nobody asked for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
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 };
|
|
|
|
// 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 here costs nothing on top.
|
|
const where = and(eq(soulseekBrowseDirs.snapshotId, snapshotId), sql`${foldedName} ilike ${likePattern(term)}`);
|
|
|
|
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)));
|
|
}
|