From c11f45c033839cf4e3caeae8939ec1866ce5f69e Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 02:55:02 +0000 Subject: [PATCH] soulseek, extracted from the platform as a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + PLUGIN.md | 94 ++++++ README.md | 38 +++ api/router.ts | 25 ++ db/index.ts | 26 ++ db/queries.ts | 398 +++++++++++++++++++++++ db/schema.ts | 114 +++++++ icon.png | Bin 0 -> 3570 bytes manifest.ts | 49 +++ sidecar/browse.ts | 176 +++++++++++ sidecar/config.ts | 114 +++++++ sidecar/download.ts | 56 ++++ sidecar/index.ts | 190 +++++++++++ sidecar/officer.ts | 195 ++++++++++++ sidecar/upstream.ts | 75 +++++ web/Cards.tsx | 127 ++++++++ web/SearchResults.tsx | 614 ++++++++++++++++++++++++++++++++++++ web/SearchView.tsx | 276 ++++++++++++++++ web/SharesBrowser.tsx | 435 +++++++++++++++++++++++++ web/SoulseekChat.tsx | 267 ++++++++++++++++ web/SoulseekConnection.tsx | 175 ++++++++++ web/SoulseekDashboard.tsx | 166 ++++++++++ web/SoulseekNav.tsx | 77 +++++ web/SoulseekRooms.tsx | 284 +++++++++++++++++ web/SoulseekSystem.tsx | 145 +++++++++ web/SoulseekTransfers.tsx | 496 +++++++++++++++++++++++++++++ web/SoulseekUploads.tsx | 229 ++++++++++++++ web/SoulseekUsers.tsx | 235 ++++++++++++++ web/SoulseekView.tsx | 66 ++++ web/UserMenu.tsx | 59 ++++ web/layout.ts | 11 + web/panels.ts | 14 + web/shared.ts | 411 ++++++++++++++++++++++++ web/useSoulseekBrowse.ts | 148 +++++++++ web/useSoulseekFavorites.ts | 47 +++ web/useSoulseekUser.ts | 45 +++ 36 files changed, 5878 insertions(+) create mode 100644 .gitignore create mode 100644 PLUGIN.md create mode 100644 README.md create mode 100644 api/router.ts create mode 100644 db/index.ts create mode 100644 db/queries.ts create mode 100644 db/schema.ts create mode 100644 icon.png create mode 100644 manifest.ts create mode 100644 sidecar/browse.ts create mode 100644 sidecar/config.ts create mode 100644 sidecar/download.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/officer.ts create mode 100644 sidecar/upstream.ts create mode 100644 web/Cards.tsx create mode 100644 web/SearchResults.tsx create mode 100644 web/SearchView.tsx create mode 100644 web/SharesBrowser.tsx create mode 100644 web/SoulseekChat.tsx create mode 100644 web/SoulseekConnection.tsx create mode 100644 web/SoulseekDashboard.tsx create mode 100644 web/SoulseekNav.tsx create mode 100644 web/SoulseekRooms.tsx create mode 100644 web/SoulseekSystem.tsx create mode 100644 web/SoulseekTransfers.tsx create mode 100644 web/SoulseekUploads.tsx create mode 100644 web/SoulseekUsers.tsx create mode 100644 web/SoulseekView.tsx create mode 100644 web/UserMenu.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/shared.ts create mode 100644 web/useSoulseekBrowse.ts create mode 100644 web/useSoulseekFavorites.ts create mode 100644 web/useSoulseekUser.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..0b8d565 --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,94 @@ +# Soulseek — the extraction record + +Extracted from the platform on **2026-08-19**, the seventh plugin out, after transmission. +`plugins/EXTRACTING-A-PLUGIN.md` is the runbook. + +## This one was a resurrection, not a move + +Every other extraction so far moved working code. **Soulseek was already dead when it left**, and had +been since 2026-08-13: + +- `src/servers/hono.ts` had both the import and the `PROTECTED_MOUNTS` entry commented out + (`// plugin — switched off 2026-08-13`), so nothing mounted `/api/slskd`. +- Because `api/slskd/router.ts` was therefore imported by nobody, `createSidecarProxy` never ran — so + `registerProxiedPrefix('/api/slskd')` was never called and the `slskd:server` port announcement fired + into a void. +- `officer-slskd` was not in `ecosystem.config.cjs` at all, so the sidecar was not even started. +- The permission entry had been reduced to `api: []`, which is why `assertPermissionTotality` was + satisfied and the server booted cleanly. **The UI rendered perfectly and every request 404'd.** + +That last point is the one worth keeping. The boot check is a totality check, not a liveness check: it +proves every mounted prefix has a permission, and is silent about a feature that mounts nothing at all. +A screen whose panels all fail is invisible to it. + +There was also a live data hazard, unrelated to the routing: `src/databases/officer_db/src/schema.ts` +had `export * from './soulseek/schema'` commented out, so the three tables were **not in the drizzle +barrel** — `bun db:push` would have dropped `soulseek_favorites`, `soulseek_browse_snapshots` and +`soulseek_browse_dirs` on any server holding them. `scripts/rebuild-soulseek-tree.ts` documented the +workaround rather than the problem. Moving the schema into the plugin closes that. + +## What moved + +| Was | Is now | +| ------------------------------------------------------- | --------------- | +| `src/servers/sidecar/slskd/*` (6 files) | `sidecar/*` | +| `src/servers/api/slskd/router.ts` | `api/router.ts` | +| `src/databases/officer_db/src/soulseek/*` (3 files) | `db/*` | +| `src/workspaces/officerdev/src/apps/Soulseek/*` (20 files) | `web/*` | +| `src/apps/officer-web/Screens/Dashboard/Soulseek/*` | `web/layout.ts` | +| `public/slskd.png` | `icon.png` | +| `scripts/rebuild-soulseek-tree.ts` | deleted | + +## The prefix changed, and that is not cosmetic + +The mount moved from `/api/slskd` to `/api/soulseek`, because `mountPrefix` derives it from the +**appName** and this plugin is `soulseek`. Three consequences, all handled here: + +1. All 47 frontend call sites were repointed from `/slskd/...` to `/soulseek/...`. +2. `useServiceConnection('slskd')` became `useServiceConnection('soulseek')` — that hook builds + `` `/${service}/_config` ``, so its argument is the **mount name**, not the credential key. +3. The credential key in `service_connections` stays **`'slskd'`**. It names the daemon, not the route. + +Point 3 is the one that will trip somebody. `service` in the database and the URL segment are now +different strings on purpose, and conflating them silently reads the wrong row. + +## The part that is not self-contained + +**The connection lives in `service_connections`, a CORE table** — the same one transmission uses. +`'slskd'` is a member of the platform's `ServiceName` union, which a plugin cannot extend. + +Deliberate, and the table says so in its own comment: it exists for *"services the owner has exactly ONE +of"*. Nobody has two Soulseek daemons, so there is no label, no `is_active` and no switcher. It also +gives encryption at rest for free, which a private table would have to reimplement. + +The cost, plainly: **this plugin depends on a core facility it does not own.** If `service_connections` +changes shape, this breaks with it. + +Unlike transmission, this plugin **does** declare a schema — the connection is core, but favourites and +the cached browse tree are its own. + +## Fixtures this broke, and what replaced them + +`slskd` was a canonical app-store test fixture in three files, the third extraction in a row to break +them. `installer.test.ts` now builds a **literal** fixture rather than reaching into the catalogue: + +```ts +const noMemberConcept = { ...byId('jellyfin')!, id: 'test-single-tenant', members: 'none' as const }; +``` + +That test pins `planSteps`, which is installer logic. Binding it to catalogue contents is why it broke +three times; no catalogue entry is both `provisioned` and `members: 'none'` any more, so there was +nothing left to borrow. + +`catalogue.test.ts` and `members.test.ts` were re-anchored to `vnc`/`notify`/`gitea`, which are real +remaining single-tenant entries. + +## Not carried over + +`composeTemplate: 'slskd'` pointed at a template directory containing **only `assets/icon.png` and no +`docker-compose.yaml`**, so the catalogue's `modes: ['existing', 'provisioned']` was already unbackable. +Like photos and transmission, this plugin is **point-at-your-own-daemon only** until plugin Docker +provisioning exists. + +The SignalR hub proxy (`/hub/*`) was a TODO in the old sidecar and never built. Live search results +still arrive by polling. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b18f81d --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Soulseek + +Search and download from the Soulseek network, through your own [slskd](https://github.com/slskd/slskd) +daemon — inside Officer, with the API key held server-side so no device ever carries it. + +## What you get + +- **Search** the network, with results grouped by peer and filterable by bitrate, size and free slots +- **Transfers** — downloads and uploads, with queue position, retry and cancel +- **Browse a peer's shares** as a tree, cached locally so it survives them going offline +- **Favourites** — peers worth coming back to +- **Rooms and chat**, joined and available +- **Server status** — connect, disconnect and see what the daemon is doing + +Favourites and the cached browse tree are Officer's own: slskd has no concept of either. + +## Requirements + +A running slskd instance Officer can reach, and its API key. + +## Setup + +Install the plugin, open **/soulseek**, and fill in the connection panel — the daemon URL and the API +key. The key is encrypted at rest and never sent to the browser. + +The plugin does **not** install or supervise slskd. Point it at one you already run. + +## Permissions + +One permission, `soulseek`, grantable per role like any other app permission. It gates the whole +surface. + +## Notes + +- The connection is stored per-owner in the platform's `service_connections` table under the service + name **`slskd`**, while the HTTP mount is **`/api/soulseek`**. Those two strings differ on purpose — + see `PLUGIN.md`. +- Live search results arrive by polling; the SignalR hub is not proxied. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..571b9a8 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,25 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; + +// 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/*` — favourites and the browse tree, features slskd has no concept +// of). Both are opaque from here; the sidecar injects the slskd API key. +// +// The prefix is DERIVED, never written out. `mountPrefix` returns `/soulseek` for a first-party plugin +// and `/p//soulseek` for anyone else's, so a literal would be right by coincidence today and +// silently wrong the moment this is published by someone other than officerdev. `createSidecarProxy` +// also calls `registerProxiedPrefix` with it, which is what stops `bodyParser` consuming the streams +// this router only forwards. +const proxy = createSidecarProxy({ + name: 'slskd', + prefix: `/api${mountPrefix({ appName: 'soulseek', manifest })}`, +}); + +export const router = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it has not reported in yet. */ +export const getSlskdServerUrl = proxy.getHttpUrl; diff --git a/db/index.ts b/db/index.ts new file mode 100644 index 0000000..02ed187 --- /dev/null +++ b/db/index.ts @@ -0,0 +1,26 @@ +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/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..1215adc --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,398 @@ +import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from './schema'; + +/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */ +export async function getSoulseekFavorites(userId: number): Promise { + 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/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..22d29ed --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,114 @@ +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'), + ], +); diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..980e0078c7af13cba3537bbc6fad6d2f75ff4a7c GIT binary patch literal 3570 zcmbtX4Nw$i7~Vrk#ViJ6Y)qT&7@ZRKcK7bMyWKntK>_uIlo0%7Z+8zi?rzW9#XBsF zam>;hCvC*ElFBJ`EJH^o9V$-7#>|o&$DbkvO@(xV8~>OfroO#h_J9rry4f4{`}X~w z_j#Z9*_XLZISVr;#7vHXAZS8XX8Ixs(%hB4V;=!Kr_MKT27ktRG7AI<8b49`YM@;o zJPAS3C%N2wG2ga;qyps}@j|YjAl~Sht|)1QbL4ln2Fim{e0JkfGH$PpQ`#O3u(I>ttQ5TNFSn zuEu(bd65^2`9Yx7$NdrjZEZF+M?@|zmzqKl=d1x`D1twI$TTlYiM%(L=bgQs%IT#H>&-eGes&4xW_X|QT%QAMIwi7JE$EsF!Hfu&oU6x4 z13@Mei8w}Lm>g>38O~7_DKuG6;)Z^qz%vXbQV$JgXwt!ZT@)yob5TVs>TwroVO2&l zjd${1kQlHtgjm>YWR_bHDL2h#rCYV2W*x^dq{Zkkm{{C`(5#U{2!oM8EOxU2u}~ID z?=aa}hE~s~^K_}yS?Rp&a)ze?MmVbx!|W7evLFVB*^aOlqY0t(6u?ae8wN9ObYOTd zn`|!!UNp+t7gb`#07TYkbeN1rGeXhHX2f7O;)tEHQwWP=IF`&}goSWOZVnlQ%;W^n zx-#YcSj?_g?wlMP@&rlIlGm+TT52-OXq9a)f*wQ4tep21vjFq~&6l$ayhHR+UN*G| zX!lTap@WeZs5K9!KD1WKoFY;0XU36GS7f1!DR&VICMT+uib16jBR2&d*ssvDKI5o2 z9LzZBR-LY3q0VR44WPYX`nK+kvqR9B#;o+zT>r0sUOeV{3Xk3RVs=g7iGxXSdqq#x z#nj1#npduGBCa!IW3QDS{^W9E;WItk{-&k~ryqw}YJ2n=XeD{s$ca~Px%xIZC1{yt8x?)A>g`YK0>KN%#*UFZ>4u{A` z{KWg?TK?I$r!leq+sbLL&QriZSpUdNqx;ZRxeTNqn6&zuJ9_tLTT;HLIZ!$6b4V9r z^gssTJbg)n3M!(HZ>@{ZpF6dDDYXKbpRZZoHWUtkQzQwqYj##_Kb*As#+{pOcRsw| zE!?i_d9*pp^YO475Jpd;44PnL(emzl=gze5Y%D2gsNOPuL~W2osCeEfb#>ITN-PoD zPt@%gv3x-BiXCzFvu=ESd%Uxt;l%M7Qq`9`eyMNX-_hE!z4;B9XIT8m_6R@k+nmCJ zrgN(brt1crm6Flx_%B;-zLs5HwW19QURl-NYwJNhJkU2v&o0Dgv{$FpY)Sd) zhi{fv`l~x{bsoIm+1Prk`)UM_%Lb0ju%F}d^QIiF>I#ru#zd&`{GkmaXc5RFC#rb& z_}1=ErQ*BZgU(+#v>|?=E5R!p;jJB{;z^)l{@%u0ZD&tB7AyxQKlXa>C>T13Iz58Z zuYP|ira330Di2zjdMYkYarbchAOi;9v3S2~Xt>#SHYT_W44Nq5#<1E$Rv)xNa^w6r z!PKgGo9sFq)%kKkL(CC*Pz}d+;ieu*n{srS#=Un>cH;63P$x90cRCKDvno*pRRa^9 z)1h_q6qsSPPlW|(15u`lHQPXa4Zllbh3k_`RLG~AhcxP2PB_s9S5;9vKfV}0$pGu81=OLmo{l0{IY8jd*vO-&0tNadm(`?mwuIH%~_a! JV0Pj9e*skwAYT9g literal 0 HcmV?d00001 diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..9d04b56 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,49 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Soulseek — search and download from the Soulseek network, through your own slskd daemon. + * + * ── What this plugin is, and what it is not ── + * + * It does not install or supervise slskd, and it shares no files itself. You point it at a daemon and it + * speaks slskd's API on your behalf, holding the API key server-side so no device ever carries it. + * + * ── Where the connection lives ── + * + * In `service_connections`, a CORE table — the same one Transmission uses, keyed `(user_id, service)` + * and already encrypted at rest. That table was designed for exactly this shape and says so: services + * the owner has exactly ONE of. Nobody has two Soulseek daemons, so there is no label, no `is_active` + * and no switcher. + * + * It does mean this plugin depends on a core facility it does not own — `'slskd'` is a member of the + * platform's `ServiceName` union, which a plugin cannot extend. Recorded in PLUGIN.md rather than + * hidden, because it is the one part of this extraction that is not self-contained. + * + * ── What it DOES own ── + * + * Three tables, in `db/schema.ts`: favourites, and the two halves of the cached browse tree. Those are + * Officer's own features — slskd has no concept of either — and they are served by the sidecar's + * `/_officer/*` routes rather than proxied upstream. + * + * ── No `osDependencies` ── + * + * Nothing shells out. The sidecar speaks slskd's HTTP API over `fetch`. + */ +export const manifest: PluginManifest = { + 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', + icon: 'Music2', + color: '#22c55e', + + permissions: [ + { + key: 'soulseek', + label: 'Soulseek', + description: 'Search and download from the Soulseek network', + }, + ], +}; diff --git a/sidecar/browse.ts b/sidecar/browse.ts new file mode 100644 index 0000000..5a36d60 --- /dev/null +++ b/sidecar/browse.ts @@ -0,0 +1,176 @@ +import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from '../db'; +import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from '../db'; +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/sidecar/config.ts b/sidecar/config.ts new file mode 100644 index 0000000..ea8640b --- /dev/null +++ b/sidecar/config.ts @@ -0,0 +1,114 @@ +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/sidecar/download.ts b/sidecar/download.ts new file mode 100644 index 0000000..84e5a12 --- /dev/null +++ b/sidecar/download.ts @@ -0,0 +1,56 @@ +import { getSoulseekBrowseDownload } from '../db'; +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/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..5c0a268 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,190 @@ +import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; +import { createSidecarConnector } from '@@/sidecar/connect'; +import { resetStaleSoulseekBrowses } from '../db'; +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/sidecar/officer.ts b/sidecar/officer.ts new file mode 100644 index 0000000..02a3db1 --- /dev/null +++ b/sidecar/officer.ts @@ -0,0 +1,195 @@ +import { + getSoulseekFavorites, + addSoulseekFavorite, + removeSoulseekFavorite, + getSoulseekBrowseSnapshots, + getSoulseekBrowseSnapshot, + getSoulseekBrowseLevel, + searchSoulseekBrowseTree, + getSoulseekBrowseDirFiles, + deleteSoulseekBrowse, +} from '../db'; +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/sidecar/upstream.ts b/sidecar/upstream.ts new file mode 100644 index 0000000..d42f47e --- /dev/null +++ b/sidecar/upstream.ts @@ -0,0 +1,75 @@ +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/web/Cards.tsx b/web/Cards.tsx new file mode 100644 index 0000000..8534f9d --- /dev/null +++ b/web/Cards.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from 'react'; +import { ChevronRight } from 'lucide-react'; + +// Shared visual language for the /soulseek grouped views (search results + downloads). Each top-level +// grouping (a peer/user) is an almost-black Card; each folder within it is a lifted SubCard. Kept here +// so search and downloads stay pixel-identical — change the look in one place. + +export const Card = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +// Supply exactly one of `title` (inert text — the whole row becomes the collapse toggle) or `titleMenu` +// (an interactive node such as the peer dropdown). A trigger can't live inside the toggle button, so with +// `titleMenu` the row splits: the chevron and the empty space after the node toggle, the node itself doesn't. +type CardHeaderProps = { + open: boolean; + onToggle: () => void; + title?: ReactNode; + meta?: ReactNode; + titleMenu?: ReactNode; +}; +export const CardHeader = ({ open, onToggle, title, meta, titleMenu }: CardHeaderProps) => { + const chevron = ( + + ); + const metaBlock = meta &&
{meta}
; + + if (!titleMenu) + return ( + + ); + + return ( +
+ + {titleMenu} +
+ ); +}; + +export const CardBody = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +export const SubCard = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +// A folder header. When `onToggle` is supplied it becomes a collapse control (chevron + clickable +// label); `action` stays a sibling so it never nests inside the toggle button. +type SubCardHeaderProps = { + icon?: ReactNode; + label: ReactNode; + title?: string; + meta?: ReactNode; + action?: ReactNode; + open?: boolean; + onToggle?: () => void; +}; +export const SubCardHeader = ({ icon, label, title, meta, action, open, onToggle }: SubCardHeaderProps) => { + const inner = ( + <> + {onToggle && ( + + )} + {icon} + + {label} + + {meta && {meta}} + + ); + return ( +
+ {onToggle ? ( + + ) : ( + inner + )} + {action} +
+ ); +}; + +// File rows inside a SubCard, hairline-divided. +export const RowList = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +type PillProps = { active: boolean; onClick: () => void; children: ReactNode }; +export const Pill = ({ active, onClick, children }: PillProps) => ( + +); diff --git a/web/SearchResults.tsx b/web/SearchResults.tsx new file mode 100644 index 0000000..5a4cdb7 --- /dev/null +++ b/web/SearchResults.tsx @@ -0,0 +1,614 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { toast } from 'sonner'; +import { + ArrowLeft, + ChevronDown, + ChevronUp, + Download, + Lock, + Loader2, + Folder, + Zap, + Users, + Search, + X, + RefreshCw, +} from 'lucide-react'; +import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards'; +import { UserMenu } from './UserMenu'; +import { + SLSKD_REFRESH_CHANNEL, + basename, + formatSize, + formatSpeed, + formatDuration, + groupResponses, + type ResultUser, + type ResultFolder, + type ResultFile, + type SlskdResponse, + type SlskdSearchSummary, +} from './shared'; + +// The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses), +// never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files, +// each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus +// per-folder download actions. +// +// Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of +// reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest +// of its folders. Download buttons always queue exactly what is on screen. +// +// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in +// the background. Never serve the cache alone — opening a search before its first responses land used to +// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in +// live; the header's refresh re-reads on demand. + +const MAX_USERS = 200; +const POLL_MS = 2500; +// Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling). +const MAX_POLLS = 48; +const resultsCache = new Map(); + +export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId); + +const fileKey = (username: string, filename: string) => `${username}::${filename}`; + +// Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query). +const LOSSLESS = new Set(['flac', 'wav', 'aiff', 'aif', 'ape', 'alac', 'wv']); +const EXT_PILLS = ['flac', 'mp3', 'wav', 'm4a', 'ogg']; +const BITRATE_PILLS = [256, 320]; + +type Filters = { + text: string; + freeOnly: boolean; + hideLocked: boolean; + exts: Set; + minBitrate: number | null; +}; +const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, exts: new Set(), minBitrate: null }; + +const filtersActive = (f: Filters) => + f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null; + +// Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track +// name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to +// the whole folder alongside its matching files, and can be expanded back to the full album in place — +// otherwise finding it would be the same act as hiding the rest of it. +type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number }; +type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number }; + +const folderKey = (username: string, path: string) => `${username}::${path}`; +// Same reveal set, for "this peer's folders that matched nothing" — the discography around the album. +const userKey = (username: string) => `user::${username}`; + +const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => { + const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean); + const pos = terms.filter((t) => !t.startsWith('-')); + const neg = terms + .filter((t) => t.startsWith('-')) + .map((t) => t.slice(1)) + .filter(Boolean); + const fileOk = (file: ResultFile) => { + if (f.hideLocked && file.isLocked) return false; + if (f.exts.size && !f.exts.has(file.extension)) return false; + if (f.minBitrate !== null && !((file.bitRate ?? 0) >= f.minBitrate || LOSSLESS.has(file.extension))) return false; + const hay = file.filename.toLowerCase(); + if (pos.some((t) => !hay.includes(t))) return false; + if (neg.some((t) => hay.includes(t))) return false; + return true; + }; + const out: UserMatch[] = []; + for (const user of users) { + if (f.freeOnly && !user.hasFreeUploadSlot) continue; + const folders: FolderMatch[] = []; + for (const folder of user.folders) { + const matched = folder.files.filter(fileOk); + if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) }); + } + if (folders.length) { + out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) }); + } + } + return out; +}; + +// `initial` is the history row we came from, when there is one. Opening by URL — or landing here the +// moment a search is created — has no row yet, so the summary is fetched like everything else and the +// header fills in a beat later. +type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void }; + +export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => { + const client = useClient(); + const [, bumpRefresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); + const cachedInit = resultsCache.get(searchId) ?? null; + const [users, setUsers] = useState(cachedInit); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [summary, setSummary] = useState(initial ?? null); + const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false); + // Users start expanded; toggling collapses them. + const [open, setOpen] = useState>(() => new Set(cachedInit?.map((u) => u.username))); + const [queued, setQueued] = useState>(new Set()); + const [filters, setFilters] = useState(EMPTY_FILTERS); + // Folders the reader has expanded past the filter, keyed username::path. + const [revealed, setRevealed] = useState>(new Set()); + // Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening + // the cards the reader deliberately collapsed. + const seenRef = useRef>(new Set(cachedInit?.map((u) => u.username) ?? [])); + const aliveRef = useRef(true); + const pollsRef = useRef(0); + + const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]); + + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); + + const refresh = useCallback( + async (quiet: boolean) => { + if (!quiet) setLoading(true); + try { + // The summary tells us whether to keep polling; tolerate its absence rather than fail the load. + const [responses, meta] = await Promise.all([ + client.get(`/soulseek/api/v0/searches/${searchId}/responses`), + client.get(`/soulseek/api/v0/searches/${searchId}`).catch(() => null), + ]); + if (!aliveRef.current) return; + const grouped = groupResponses(responses); + resultsCache.set(searchId, grouped); + setUsers(grouped); + setError(null); + setOpen((prev) => { + const next = new Set(prev); + for (const u of grouped) { + if (!seenRef.current.has(u.username)) { + seenRef.current.add(u.username); + next.add(u.username); + } + } + return next; + }); + if (meta) { + setSummary(meta); + setIsComplete(meta.isComplete); + } + } catch (err) { + if (aliveRef.current) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (aliveRef.current) setLoading(false); + } + }, + // useClient() returns a fresh object each render, so depend only on the search id. + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchId], + ); + + useEffect(() => { + const cached = resultsCache.get(searchId) ?? null; + seenRef.current = new Set(cached?.map((u) => u.username) ?? []); + pollsRef.current = 0; + setUsers(cached); + setOpen(new Set(cached?.map((u) => u.username))); + setRevealed(new Set()); + setError(null); + // Assume running until the summary says otherwise — a search opened straight after being created has + // no row to tell us, and guessing "complete" would mean never polling for its first responses. + setIsComplete(initial?.id === searchId ? initial.isComplete : false); + // Quiet when something is already on screen — no spinner flash over usable results. + refresh(Boolean(cached)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchId, refresh]); + + useEffect(() => { + if (isComplete) return; + const timer = setInterval(() => { + pollsRef.current += 1; + if (pollsRef.current > MAX_POLLS) { + clearInterval(timer); + return; + } + refresh(true); + }, POLL_MS); + return () => clearInterval(timer); + }, [isComplete, refresh]); + + const toggle = (username: string) => + setOpen((prev) => { + const next = new Set(prev); + if (next.has(username)) next.delete(username); + else next.add(username); + return next; + }); + + const toggleReveal = (key: string) => + setRevealed((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const queue = async (username: string, files: ResultFile[]) => { + const usable = files.filter((f) => !f.isLocked); + if (usable.length === 0) return; + try { + await client.post( + `/soulseek/api/v0/transfers/downloads/${encodeURIComponent(username)}`, + usable.map((f) => ({ filename: f.filename, size: f.size })), + ); + setQueued((prev) => { + const next = new Set(prev); + usable.forEach((f) => next.add(fileKey(username, f.filename))); + return next; + }); + bumpRefresh(Date.now()); + toast.success(usable.length === 1 ? `Queued ${basename(usable[0]!.filename)}` : `Queued ${usable.length} files`); + } catch (err) { + toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + const toggleExt = (ext: string) => + setFilters((f) => { + const exts = new Set(f.exts); + if (exts.has(ext)) exts.delete(ext); + else exts.add(ext); + return { ...f, exts }; + }); + const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n })); + + const shown = filtered ? filtered.slice(0, MAX_USERS) : []; + const totalFiles = filtered?.reduce((n, u) => n + u.matchedCount, 0) ?? 0; + const anyFilter = filtersActive(filters); + + return ( +
+
+ +
+
+ {summary?.searchText ?? initial?.searchText ?? '…'} + {!isComplete && ( + + searching + + )} +
+
+ {users === null && !error + ? 'Loading results…' + : `${totalFiles.toLocaleString()} files from ${(filtered?.length ?? 0).toLocaleString()} users${ + anyFilter ? ' (filtered)' : '' + }${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`} +
+
+ +
+ + {/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */} + {users && users.length > 0 && ( +
+
+ + setFilters((f) => ({ ...f, text: ev.target.value }))} + placeholder="Filter files… (space-separated, prefix - to exclude)" + className="h-9 w-full rounded-lg border bg-background pl-9 pr-8 text-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> + {filters.text && ( + + )} +
+
+ setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}> + Free slots + + setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}> + Hide locked + + + {EXT_PILLS.map((ext) => ( + toggleExt(ext)}> + {ext.toUpperCase()} + + ))} + + {BITRATE_PILLS.map((br) => ( + setMinBitrate(br)}> + ≥{br} + + ))} + {anyFilter && ( + + )} +
+
+ )} + +
+ {error &&

Could not load results: {error}

} + {!error && users === null && ( +
+ Loading results… +
+ )} + {!error && users && users.length === 0 && ( +

+ {isComplete ? ( + 'No results stored.' + ) : ( + <> + Waiting for responses… + + )} +

+ )} + {!error && users && users.length > 0 && filtered && filtered.length === 0 && ( +

No files match the current filters.

+ )} + +
+ {shown.map((match) => ( + toggle(match.user.username)} + queued={queued} + onDownload={queue} + revealed={revealed} + onToggleReveal={toggleReveal} + /> + ))} +
+
+
+ ); +}; + +type UserCardProps = { + match: UserMatch; + open: boolean; + onToggle: () => void; + queued: Set; + onDownload: (username: string, files: ResultFile[]) => void; + revealed: Set; + onToggleReveal: (key: string) => void; +}; + +const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => { + const { user, folders, matchedCount } = match; + // A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings + // the non-matching folders back, each already whole. + const allFolders = revealed.has(userKey(user.username)); + const hiddenFolders = user.folders.length - folders.length; + const visible: FolderMatch[] = allFolders + ? user.folders.map( + (folder) => + folders.find((fm) => fm.folder.path === folder.path) ?? { + folder, + matched: folder.files, + matchedSize: folder.size, + }, + ) + : folders; + // Every action queues what is on screen, expanded folders included — never the hidden remainder. + const shownFiles = visible.flatMap((fm) => + revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched, + ); + const meta = ( + <> + {user.hasFreeUploadSlot ? ( + + free + + ) : ( + + {user.queueLength} + + )} + {user.uploadSpeed > 0 && {formatSpeed(user.uploadSpeed)}} + + {matchedCount < user.fileCount + ? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files` + : `${user.fileCount.toLocaleString()} files`} + + + ); + return ( + + } meta={meta} /> + {open && ( + + {visible.map((fm) => ( + onToggleReveal(folderKey(user.username, fm.folder.path))} + queued={queued} + onDownload={onDownload} + /> + ))} + {hiddenFolders > 0 && ( + + )} + {shownFiles.length > 1 && ( + + )} + + )} + + ); +}; + +type FolderBlockProps = { + username: string; + match: FolderMatch; + revealed: boolean; + onToggleReveal: () => void; + queued: Set; + onDownload: (username: string, files: ResultFile[]) => void; +}; + +const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => { + const { folder, matched, matchedSize } = match; + const [open, setOpen] = useState(true); + const files = revealed ? folder.files : matched; + const hidden = folder.files.length - matched.length; + const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null; + const unlocked = files.filter((f) => !f.isLocked).length; + return ( + + setOpen((v) => !v)} + icon={} + label={folder.label} + title={folder.path} + meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize( + revealed ? folder.size : matchedSize, + )}`} + action={ + files.length > 1 ? ( + + ) : null + } + /> + {open && ( + <> + {/* The point of the whole exercise: the track you filtered for has found the album, so open the + album. Revealing also widens every download button below to the full folder. */} + {hidden > 0 && ( + + )} + + {files.map((file) => { + const isQueued = queued.has(fileKey(username, file.filename)); + const duration = formatDuration(file.length); + // In an expanded folder, keep the file you filtered for legible among its neighbours. + const isMatch = !matchedNames || matchedNames.has(file.filename); + return ( +
+ + {file.isLocked && } + + {file.name} + + +
+ {file.bitRate ? {file.bitRate} kbps : null} + {duration && {duration}} + {formatSize(file.size)} +
+ +
+ ); + })} +
+ {unlocked > 1 && ( + + )} + + )} +
+ ); +}; diff --git a/web/SearchView.tsx b/web/SearchView.tsx new file mode 100644 index 0000000..8fe3049 --- /dev/null +++ b/web/SearchView.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; +import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react'; +import { SearchResults, dropCachedResults } from './SearchResults'; +import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared'; +import { randomId } from 'helpers/random-id'; + +// The 'search' section — a search input plus the history of past searches (GET /searches). Which search +// is open lives in `?search=`, so rows are real links and the back button returns to the history. +// +// Submitting opens the new search's results straight away, the way slskd's own web UI does. That is not +// only a nicety: a history row is only as fresh as the last list load, so a search you had just started +// sat at 0 responses looking stalled, while the results view polls for as long as the search runs. + +const HISTORY_POLL_MS = 4000; +// A running search nobody is watching shouldn't poll forever (~3 minutes). +const MAX_HISTORY_POLLS = 45; + +const byNewest = (list: SlskdSearchSummary[]) => [...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + +// slskd accepts a client-supplied id (its own UI depends on that), but don't bet the navigation on it: +// prefer an id the server has actually stored, then fall back to the newest search with this text. +type CreatedIdParams = { list: SlskdSearchSummary[] | null; sentId: string; returnedId?: string; text: string }; +const createdSearchId = ({ list, sentId, returnedId, text }: CreatedIdParams) => { + if (!list) return returnedId ?? sentId; + const known = new Set(list.map((s) => s.id)); + if (returnedId && known.has(returnedId)) return returnedId; + if (known.has(sentId)) return sentId; + return list.find((s) => s.searchText === text)?.id ?? returnedId ?? sentId; +}; + +export const SearchView = () => { + const client = useClient(); + const [params, setParams] = useSearchParams(); + const [query, setQuery] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [history, setHistory] = useState(null); + const [error, setError] = useState(null); + const [clearing, setClearing] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + + const selectedId = params.get(SEARCH_PARAM); + + const openSearch = useCallback( + (id: string | null) => + setParams((prev) => { + const next = new URLSearchParams(prev); + if (id) next.set(SEARCH_PARAM, id); + else next.delete(SEARCH_PARAM); + return next; + }), + [setParams], + ); + + const fetchHistory = useCallback(async () => { + const list = await client.get('/soulseek/api/v0/searches'); + return byNewest(list ?? []); + // useClient() is a fresh object each render — keep this callback stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const load = useCallback(() => { + setError(null); + fetchHistory() + .then(setHistory) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, [fetchHistory]); + + // Re-read whenever the history is what's on screen — including a browser Back out of a search, which + // no callback of ours sees. + useEffect(() => { + if (!selectedId) load(); + }, [selectedId, load]); + + // A row's counters only move when the list is re-read, so keep re-reading while a search is running — + // but not while its results are open, since that view polls on its own. + const running = !!history?.some((s) => !s.isComplete); + useEffect(() => { + if (!running || selectedId) return; + let polls = 0; + const timer = setInterval(() => { + polls += 1; + if (polls > MAX_HISTORY_POLLS) { + clearInterval(timer); + return; + } + load(); + }, HISTORY_POLL_MS); + return () => clearInterval(timer); + }, [running, selectedId, load]); + + const submit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const text = query.trim(); + if (!text || submitting) return; + setSubmitting(true); + const sentId = randomId(); + try { + const created = await client.post>('/soulseek/api/v0/searches', { + id: sentId, + searchText: text, + }); + // A failed reload shouldn't read as a failed search — fall through with what the POST told us. + const list = await fetchHistory().catch(() => null); + if (list) setHistory(list); + setQuery(''); + openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text })); + } catch (err) { + toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setSubmitting(false); + } + }; + + const remove = async (id: string) => { + setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev); + dropCachedResults(id); + if (selectedId === id) openSearch(null); + client.delete(`/soulseek/api/v0/searches/${id}`).catch(() => load()); + }; + + // slskd has no bulk delete (0.26.0 exposes only DELETE /searches/{id}), so clearing fans out one + // request per search and reconciles with a reload — partial failures come back on their own. + const clearAll = async () => { + const ids = history?.map((s) => s.id) ?? []; + if (!ids.length || clearing) return; + setConfirmClear(false); + setClearing(true); + setHistory([]); + openSearch(null); + ids.forEach(dropCachedResults); + const settled = await Promise.allSettled(ids.map((id) => client.delete(`/soulseek/api/v0/searches/${id}`))); + const failed = settled.filter((r) => r.status === 'rejected').length; + if (failed) toast.error(`${failed} of ${ids.length} searches could not be removed.`); + setClearing(false); + load(); + }; + + // The destructive click is two-step rather than a modal (matching the other Soulseek panels, which + // confirm nothing) — and the armed state disarms itself so it can't sit there waiting to be hit. + useEffect(() => { + if (!confirmClear) return; + const timer = setTimeout(() => setConfirmClear(false), 4000); + return () => clearTimeout(timer); + }, [confirmClear]); + + // The row we came from, when we have it — a search opened by URL (or just created) resolves its own. + const selected = selectedId ? (history?.find((s) => s.id === selectedId) ?? null) : null; + const hrefFor = (id: string) => { + const next = new URLSearchParams(params); + next.set(SEARCH_PARAM, id); + return `?${next.toString()}`; + }; + + if (selectedId) return openSearch(null)} />; + + return ( +
+ {/* Header + search field */} +
+

Search

+
+
+ + setQuery(ev.target.value)} + placeholder="Search artists, albums, tracks…" + className="h-11 w-full rounded-xl border border-border bg-card pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> +
+ +
+
+ + {/* History */} +
+
+ + Recent searches + {history && · {history.length}} +
+
+ {!!history?.length && ( + + )} + +
+
+ +
+ {error &&

Could not load history: {error}

} + {!error && history === null && ( +
+ Loading… +
+ )} + {!error && history?.length === 0 && ( +
+ + No searches yet — try one above. +
+ )} + {history && history.length > 0 && ( +
    + {history.map((s) => ( +
  • + {/* The row is an anchor so it opens with cmd-click and keyboard like any link; the remove + button is its sibling, never nested inside it. */} + +
    + {s.isComplete ? : } +
    +
    +
    {s.searchText}
    +
    + {formatWhen(s.startedAt)} + + + {s.responseCount.toLocaleString()} + + + + {s.fileCount.toLocaleString()} + +
    +
    + + + +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/web/SharesBrowser.tsx b/web/SharesBrowser.tsx new file mode 100644 index 0000000..e99c0fd --- /dev/null +++ b/web/SharesBrowser.tsx @@ -0,0 +1,435 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + FolderOpen, + Folder, + Loader2, + Search, + RefreshCw, + Download, + ChevronRight, + ChevronDown, + TriangleAlert, + Trash2, + FileAudio, +} from 'lucide-react'; +import { + useSoulseekBrowseSnapshots, + useSoulseekBrowseLevel, + useSoulseekBrowseSearch, + useSoulseekBrowseFiles, + useSoulseekEnqueue, +} from './useSoulseekBrowse'; +import { formatSize, formatWhen, type SoulseekBrowseNode, type SoulseekBrowseSnapshot } from './shared'; + +// A peer's shared folders, read from Officer's cache rather than browsed live, and rendered as a TREE. +// +// The live slskd call returns the entire tree in one blocking response (59 MB / 18k folders / 284k files +// for a real peer) and takes minutes, because it round-trips to that peer. So nothing here triggers it +// inline: you ask the sidecar to fetch, it keeps going without the tab, and this reads back levels. +// +// slskd's response is flat — every folder is a full backslash-delimited path — which made the old list +// unreadable: 26 rows all beginning with the same 31 characters. The sidecar links the paths into a tree +// at ingest, so a row here shows one segment and one level loads at a time. +// +// Filtering never flattens that. The search route returns matches WITH their ancestors, and this renders +// that skeleton pre-expanded: you see where a hit lives, not just that it exists. Because matching runs +// against the whole path, a folder matching implies all its descendants match too — so a matched subtree +// arrives complete. + +// One level's page size. Fan-out is not gentle: the widest folder measured on a real peer has 1,181 +// children, so a level can ask for more (up to the route's cap) rather than paging with Previous/Next — +// there's nothing to page through once you're inside one folder. +const LEVEL_LIMIT = 200; +const LEVEL_LIMIT_MAX = 2000; + +/** Debounce the filter box so a keystroke doesn't become a request. */ +function useDebounced(value: T, ms: number): T { + const [held, setHeld] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setHeld(value), ms); + return () => clearTimeout(timer); + }, [value, ms]); + return held; +} + +/** Cache state for one peer, compact enough for a favourites row. */ +export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot | null }) => { + if (!snapshot) return not cached; + if (snapshot.status === 'pending') + return ( + + + fetching + + ); + if (snapshot.status === 'failed' && !snapshot.directoryCount) + return ( + + + failed + + ); + return ( + + {snapshot.directoryCount.toLocaleString()} folders + + ); +}; + +/** Queue-this button, shown on row hover. Kept out of the row's own button — nesting the two is invalid. */ +type QueueButtonProps = { onQueue: () => void; busy: boolean; title: string }; + +const QueueButton = ({ onQueue, busy, title }: QueueButtonProps) => ( + +); + +/** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */ +type Filtered = { childrenOf: Map; matched: Set }; + +export const SharesBrowser = ({ username }: { username: string }) => { + const { snapshotOf, fetchShares, dropShares } = useSoulseekBrowseSnapshots(); + const [filter, setFilter] = useState(''); + const q = useDebounced(filter.trim(), 250); + + const snapshot = snapshotOf(username); + // Folder rows survive a failed or in-flight refresh, so drive the tree off the data existing rather + // than off the status — a stale tree is still worth browsing while a new one is being fetched. + const hasCache = !!snapshot && snapshot.directoryCount > 0; + + const roots = useSoulseekBrowseLevel({ username, parent: null, limit: LEVEL_LIMIT, enabled: hasCache && !q }); + const search = useSoulseekBrowseSearch({ username, q, enabled: hasCache }); + + useEffect(() => { + setFilter(''); + }, [username]); + + const filtered = useMemo(() => { + if (!q || !search.data) return null; + const childrenOf = new Map(); + for (const node of search.data.nodes) { + if (!node.parentPath) continue; + const siblings = childrenOf.get(node.parentPath); + if (siblings) siblings.push(node); + else childrenOf.set(node.parentPath, [node]); + } + return { childrenOf, matched: new Set(search.data.matched) }; + }, [q, search.data]); + + const topNodes = q ? (search.data?.nodes.filter((n) => !n.parentPath) ?? []) : (roots.data?.nodes ?? []); + const pending = snapshot?.status === 'pending'; + const busy = q ? search.isFetching : roots.isFetching; + + return ( +
+
+ + Shared folders + {snapshot && !!snapshot.directoryCount && ( + + · {snapshot.directoryCount.toLocaleString()} folders · {snapshot.fileCount.toLocaleString()} files ·{' '} + {formatSize(snapshot.totalSize)} + + )} +
+ + {hasCache && ( + + )} +
+
+ + {/* Status banners — a fetch runs server-side, so leaving the panel is safe. */} + {pending && ( +
+ + Fetching this peer's shares in the background — it can take a few minutes for a large share, and it keeps + going if you navigate away. +
+ )} + {snapshot?.status === 'failed' && ( +
+ + + Last fetch failed{snapshot.error ? `: ${snapshot.error}` : ''}.{' '} + {hasCache && 'Showing the previously cached tree.'} + +
+ )} + + {!hasCache && !pending ? ( +
+ +

+ No cached shares for this peer yet. Fetching runs on the server and survives you closing the tab — come back + and it'll be here. +

+
+ ) : ( + hasCache && ( + <> +
+ + setFilter(ev.target.value)} + placeholder="Filter folders…" + className="h-7 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" + /> + {busy && } + {q && ( + + {(search.data?.total ?? 0).toLocaleString()} matching + + )} +
+ + {/* Never a silent cap: a truncated search still renders a valid tree, just not all of it. */} + {search.data?.truncated && q && ( +
+ Showing the first {search.data.matched.length.toLocaleString()} of {search.data.total.toLocaleString()}{' '} + matches — narrow the filter to see the rest. +
+ )} + + {q && search.data && search.data.total === 0 ? ( +

No folders match “{q}”.

+ ) : ( + // Keyed by the term so switching between browsing and a filtered view remounts the tree: + // every row's open state is local, and reusing instances across modes would strand it. +
+ {topNodes.map((node) => ( + + ))} +
+ )} + + {snapshot?.completedAt && ( +
+ cached {formatWhen(snapshot.completedAt)} +
+ )} + + ) + )} +
+ ); +}; + +type TreeNodeProps = { + username: string; + node: SoulseekBrowseNode; + /** Non-null in filter mode: children come from this set, not from a request, and rows start open. */ + filtered: Filtered | null; + indent: number; + initialOpen: boolean; +}; + +const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodeProps) => { + const [limit, setLimit] = useState(LEVEL_LIMIT); + const [revealed, setRevealed] = useState(false); + const enqueue = useSoulseekEnqueue(username); + + // Filtering stops at a revealed row: from here down it's ordinary lazy browsing, so opening a matched + // folder shows what's really inside it rather than the filtered skeleton — which is why you searched. + const scoped = revealed ? null : filtered; + const scopedChildren = scoped?.childrenOf.get(node.name) ?? []; + + // A filtered row starts open only when the filter matched something below it. One that matched on its own + // name has nothing of its own to show, so it stays shut and behaves like any other collapsed folder. + const [open, setOpen] = useState(() => (filtered ? scopedChildren.length > 0 : initialOpen)); + + const expandable = node.childCount > 0 || node.fileCount > 0; + const level = useSoulseekBrowseLevel({ + username, + parent: node.name, + limit, + enabled: !scoped && open && node.childCount > 0, + }); + + const children = scoped ? scopedChildren : (level.data?.nodes ?? []); + // In the filtered skeleton no level was fetched, so the row's own count is what says how many exist. + const total = scoped ? node.childCount : (level.data?.total ?? node.childCount); + const capped = !scoped && limit >= LEVEL_LIMIT_MAX; + // Not while a level is in flight: `total` falls back to the row's own count then, so every loading row + // would flash a "show all" button for children it's already fetching. + const more = open && total > children.length && (!!scoped || !level.isPending); + const isMatch = filtered?.matched.has(node.name) ?? false; + + const toggle = () => { + // Opening a matched row with nothing matched below it leaves the filter behind, rather than opening + // into an empty folder. + if (!open && scoped && !scopedChildren.length) setRevealed(true); + setOpen((v) => !v); + }; + + return ( +
+
+ + {node.subtreeFileCount > 0 && ( + enqueue.mutate({ path: node.name })} + busy={enqueue.isPending} + title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`} + /> + )} +
+ + {open && ( + <> + {!scoped && level.isPending && node.childCount > 0 && ( +

+ Loading folders… +

+ )} + {children.map((child) => ( + + ))} + {/* One button for both modes: in the filtered skeleton it drops this row out of the filter, in + browse mode it widens the level. Wide levels load in one go — there's nothing to page through. */} + {more && !capped && ( + + )} + {more && capped && ( +

+ Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders. +

+ )} + {node.fileCount > 0 && } + + )} +
+ ); +}; + +type DirFilesProps = { username: string; dirId: number; path: string; indent: number }; + +const DirFiles = ({ username, dirId, path, indent }: DirFilesProps) => { + const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId); + const enqueue = useSoulseekEnqueue(username); + const pad = { paddingLeft: 12 + indent * 14 }; + + if (isPending) + return ( +

+ Loading files… +

+ ); + if (error) + return ( +

+ Could not load this folder. +

+ ); + if (!data?.length) return null; + + return ( +
+ {data.map((file) => ( +
+ + + + {file.name} + + {formatSize(file.size)} + enqueue.mutate({ path, file: file.name })} + busy={enqueue.isPending} + title={`Queue ${file.name}`} + /> +
+ ))} +
+ ); +}; diff --git a/web/SoulseekChat.tsx b/web/SoulseekChat.tsx new file mode 100644 index 0000000..c3f17da --- /dev/null +++ b/web/SoulseekChat.tsx @@ -0,0 +1,267 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; +import { MessageCircle, Send, Plus, RefreshCw, X } from 'lucide-react'; +import { formatClock, soulseekPeerPath, PEER_PARAM, type SlskdConversation, type SlskdPrivateMessage } from './shared'; + +// Chat panel — private (1:1) messaging. GET /conversations lists the peers we have threads with; GET +// /conversations/{user}/messages returns a thread (polled while it's open). Sending POSTs a bare JSON +// string body to /conversations/{user}, matching slskd's [FromBody] string endpoint; viewing a thread +// PUTs /conversations/{user} to acknowledge (clear the unread badge). Basic version: a peer rail on the +// left, transcript + composer on the right. + +const POLL_MS = 2500; + +const isMine = (m: SlskdPrivateMessage) => (m.direction ?? '').toLowerCase() === 'out'; + +export const SoulseekChat = () => { + const client = useClient(); + const [params, setParams] = useSearchParams(); + const selected = params.get(PEER_PARAM)?.trim() || null; + const [conversations, setConversations] = useState([]); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(''); + const [peerName, setPeerName] = useState(''); + const scrollRef = useRef(null); + + const loadConversations = useCallback(async () => { + try { + setConversations(await client.get('/soulseek/api/v0/conversations')); + } catch { + /* status panel surfaces connection errors */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadConversations(); + }, [loadConversations]); + + // Poll the selected thread's messages while it's open, and acknowledge to clear its unread badge. + useEffect(() => { + if (!selected) { + setMessages([]); + return; + } + let cancelled = false; + const tick = () => + client + .get(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}/messages`) + .then((m) => { + if (cancelled) return; + setMessages(m); + setConversations((prev) => + prev.map((c) => (c.username === selected ? { ...c, unAcknowledgedMessageCount: 0 } : c)), + ); + client.put(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}`).catch(() => {}); + }) + .catch(() => {}); + tick(); + const timer = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + // useClient() is a fresh object each render — re-poll only when the selected peer changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + + // Stick to the bottom as new messages arrive / when switching threads. + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages.length, selected]); + + // Starts a thread that isn't in the list yet, so it stays a form submit rather than a link: it adds the + // peer locally *and then* opens it, and a link cannot express the "and then". + const openPeer = (name: string) => { + const target = name.trim(); + if (!target) return; + setPeerName(''); + setConversations((prev) => (prev.some((c) => c.username === target) ? prev : [...prev, { username: target }])); + setParams((prev) => { + const next = new URLSearchParams(prev); + next.set(PEER_PARAM, target); + return next; + }); + }; + + const close = async (name: string) => { + try { + await client.delete(`/soulseek/api/v0/conversations/${encodeURIComponent(name)}`); + } catch { + /* optimistic — drop it locally regardless */ + } + setConversations((prev) => prev.filter((c) => c.username !== name)); + // Closing the open thread leaves nothing open. `replace`, because the thread you just deleted is not + // somewhere Back should return you to. + if (name === selected) { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete(PEER_PARAM); + return next; + }, + { replace: true }, + ); + } + }; + + const send = async () => { + const text = draft.trim(); + if (!text || !selected) return; + setDraft(''); + try { + await client.post(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}`, text); + } catch (err) { + setDraft(text); + toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + return ( +
+ {/* Peer rail */} +
+
+ Conversations + +
+
+ {conversations.length === 0 && ( +

No conversations yet.

+ )} + {conversations.map((c) => { + const active = c.username === selected; + const unread = c.unAcknowledgedMessageCount ?? 0; + return ( +
+ {/* The close button is a sibling, never nested inside the anchor. */} + + + {c.username} + + {unread > 0 && !active && ( + + {unread} + + )} + +
+ ); + })} +
+ + {/* Start a conversation */} +
+
{ + ev.preventDefault(); + openPeer(peerName); + }} + className="flex items-center gap-1.5" + > + setPeerName(ev.target.value)} + placeholder="Message a user…" + className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50" + /> + +
+
+
+ + {/* Transcript + composer */} +
+ {!selected ? ( +
+ +

Pick a conversation, or message a user to start one.

+
+ ) : ( + <> +
+ + {selected} +
+ +
+ {messages.length === 0 ? ( +

No messages yet. Say hello.

+ ) : ( +
+ {messages.map((m, i) => { + const mine = isMine(m); + return ( +
+
+
{m.message}
+
+ {formatClock(m.timestamp)} +
+
+
+ ); + })} +
+ )} +
+ +
{ + ev.preventDefault(); + send(); + }} + className="flex shrink-0 items-center gap-2 border-t p-3" + > + setDraft(ev.target.value)} + placeholder={`Message ${selected}…`} + className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> + +
+ + )} +
+
+ ); +}; diff --git a/web/SoulseekConnection.tsx b/web/SoulseekConnection.tsx new file mode 100644 index 0000000..f4ad868 --- /dev/null +++ b/web/SoulseekConnection.tsx @@ -0,0 +1,175 @@ +import type { ServiceConnection } from 'officerdev'; +import { useEffect, useState } from 'react'; +import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + serviceErrorMessage, + useServiceConnection, + useServiceConnectionActions, + useServiceHealth, +} from 'officerdev'; + +// Connecting a slskd daemon to Officer, from the app. +// +// Both the setup wizard and the permanent settings page: SoulseekView renders it in place of whatever +// section the nav asked for while nothing is connected, and the Connection section renders it for good. +// One component, so re-pointing at a different daemon later goes through exactly the code path that +// stored the first one. +// +// The API key is write-only — the GET it reads has no field that could carry one back, so the input is +// always blank and an empty input means "keep the stored key". + +const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; + +/** slskd's own default HTTP port. Officer dials it from the server, not from this browser. */ +const DEFAULT_URL = 'http://localhost:5030'; + +const URL_HINT = + "The daemon's base URL. Officer reaches it from the server, not from this browser — so localhost here " + + 'means the machine Officer runs on.'; + +const KEY_HINT = + 'An API key from slskd.yml (web.authentication.api_keys). Officer sends it as X-API-Key on every call.'; + +type SaveInput = Record & { url: string }; + +export const SoulseekConnection = () => { + const { data, isLoading } = useServiceConnection('soulseek'); + const { data: health } = useServiceHealth('soulseek'); + const { save, forget } = useServiceConnectionActions('soulseek'); + + const connection = data?.connection ?? null; + + const [url, setUrl] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(''); + + // Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the + // owner is still typing, while forgetting and re-adding does reset the form. + useEffect(() => { + setUrl(connection?.url ?? ''); + setApiKey(''); + }, [connection?.id]); + + const submit = async () => { + setError(''); + try { + await save.mutateAsync({ url: url.trim(), apiKey: apiKey.trim() }); + setApiKey(''); + } catch (err) { + setError(serviceErrorMessage(err)); + } + }; + + const remove = async () => { + setError(''); + try { + await forget.mutateAsync(); + } catch (err) { + setError(serviceErrorMessage(err)); + } + }; + + if (isLoading) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+
+
+
+ +
+
+

slskd daemon

+

+ {connection + ? 'Where Officer talks to slskd. Saving re-checks the daemon before storing anything.' + : 'Point Officer at your slskd daemon. It needs the URL and one API key.'} +

+
+
+ + {connection && } + +
+ + + + + {error && ( +
+ + {error} +
+ )} + +
+ + {connection && ( + + )} +
+
+
+
+ ); +}; + +type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined }; + +const StatusRow = ({ connection, health }: StatusRowProps) => ( +
+ {health?.ok ? ( + + ) : ( + + )} +
+
{health?.ok ? 'Connected' : 'Not responding'}
+
+ {connection.url} + {connection.version ? ` · slskd ${connection.version}` : ''} +
+ {!health?.ok && health?.error &&
{health.error}
} +
+
+); diff --git a/web/SoulseekDashboard.tsx b/web/SoulseekDashboard.tsx new file mode 100644 index 0000000..5c0453a --- /dev/null +++ b/web/SoulseekDashboard.tsx @@ -0,0 +1,166 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Link } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react'; +import { + SEARCH_PARAM, + formatWhen, + soulseekSectionPath, + transferPhase, + toneSolid, + PHASE_TONE, + type SlskdApplication, + type SlskdSearchSummary, + type SlskdTransferUser, + type TransferPhase, +} from './shared'; + +// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live +// download/upload tallies, and the most recent searches. Read-only aggregation over the same slskd +// endpoints the other panels use; every tile and row is a real link into the section it summarises, and +// a recent search links to that search rather than to the search screen's front page. + +const POLL_MS = 2000; + +type Tally = Record; +const emptyTally = (): Tally => ({ downloading: 0, queued: 0, done: 0, failed: 0 }); +const tally = (users: SlskdTransferUser[]): Tally => { + const t = emptyTally(); + for (const u of users) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++; + return t; +}; + +export const SoulseekDashboard = () => { + const client = useClient(); + const [app, setApp] = useState(null); + const [downloads, setDownloads] = useState(emptyTally); + const [uploads, setUploads] = useState(emptyTally); + const [searches, setSearches] = useState([]); + + useEffect(() => { + let cancelled = false; + const tick = async () => { + const [a, d, u, s] = await Promise.allSettled([ + client.get('/soulseek/api/v0/application'), + client.get('/soulseek/api/v0/transfers/downloads'), + client.get('/soulseek/api/v0/transfers/uploads'), + client.get('/soulseek/api/v0/searches'), + ]); + if (cancelled) return; + if (a.status === 'fulfilled') setApp(a.value); + if (d.status === 'fulfilled') setDownloads(tally(d.value)); + if (u.status === 'fulfilled') setUploads(tally(u.value)); + if (s.status === 'fulfilled') + setSearches([...s.value].sort((x, y) => new Date(y.startedAt).getTime() - new Date(x.startedAt).getTime())); + }; + tick(); + const timer = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + // useClient() is a fresh object each render — poll on a stable interval only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const server = app?.server; + const connected = server?.isConnected ?? false; + const recent = useMemo(() => searches.slice(0, 6), [searches]); + + return ( +
+
+ {/* Connection */} +
+ + +
+
{server?.state ?? 'Unknown'}
+ {server?.address &&
{server.address}
} +
+ {app?.version?.current && ( + + v{app.version.current} + {app.version.isUpdateAvailable ? ' · update available' : ''} + + )} +
+ + {/* Downloads */} +
} to={soulseekSectionPath('downloads')}> + + + + +
+ + {/* Uploads */} +
} to={soulseekSectionPath('uploads')}> + + + + +
+ + {/* Recent searches */} +
+
+ + Recent searches + + View all + +
+ {recent.length === 0 ? ( +

No searches yet.

+ ) : ( +
+ {recent.map((s) => ( + + {s.searchText} + {s.responseCount} responses + {formatWhen(s.startedAt)} + + ))} +
+ )} +
+
+
+ ); +}; + +type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode }; + +const Section = ({ title, icon, to, children }: SectionProps) => ( +
+ + {icon} + {title} + + +
{children}
+
+); + +type TileProps = { label: string; value: number; dot: string }; + +const Tile = ({ label, value, dot }: TileProps) => ( +
+
+ + {label} +
+
{value}
+
+); diff --git a/web/SoulseekNav.tsx b/web/SoulseekNav.tsx new file mode 100644 index 0000000..f767b5c --- /dev/null +++ b/web/SoulseekNav.tsx @@ -0,0 +1,77 @@ +import type { LucideIcon } from 'lucide-react'; +import { NavLink } from 'react-router'; +import { + LayoutGrid, + Search, + ArrowDownToLine, + ArrowUpFromLine, + Hash, + MessageCircle, + Users, + Server, + Plug, +} from 'lucide-react'; +import { SOULSEEK_SECTIONS, soulseekSectionPath, type SoulseekSectionId } from './shared'; + +// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are +// react-router NavLinks to /soulseek/
, so active state comes from the router and SoulseekView +// (right) learns which section is open by reading the same URL rather than by being told. + +const ICONS: Record = { + dashboard: LayoutGrid, + search: Search, + downloads: ArrowDownToLine, + uploads: ArrowUpFromLine, + rooms: Hash, + chat: MessageCircle, + users: Users, + system: Server, + connection: Plug, +}; + +export const SoulseekNav = () => { + return ( +
+
+
+ +
+
+
Soulseek
+
slskd client
+
+
+ + +
+ ); +}; diff --git a/web/SoulseekRooms.tsx b/web/SoulseekRooms.tsx new file mode 100644 index 0000000..8846f56 --- /dev/null +++ b/web/SoulseekRooms.tsx @@ -0,0 +1,284 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; +import { Hash, Users, LogOut, Send, Plus, RefreshCw } from 'lucide-react'; +import { formatClock, soulseekRoomPath, ROOM_PARAM, type SlskdRoom, type SlskdRoomInfo } from './shared'; + +// Rooms panel — join Soulseek chat rooms and talk in them. GET /rooms/joined lists the room names we're +// in; GET /rooms/joined/{name} inlines that room's users + messages (polled while it's open); GET +// /rooms/available lists everything else to join. Messages/joins POST a bare JSON string body, matching +// slskd's [FromBody] string endpoints. Basic version: a room rail on the left, transcript + composer on +// the right. + +const POLL_MS = 2500; +const AVAILABLE_LIMIT = 250; + +export const SoulseekRooms = () => { + const client = useClient(); + const [joined, setJoined] = useState([]); + const [available, setAvailable] = useState([]); + const [params, setParams] = useSearchParams(); + const selected = params.get(ROOM_PARAM)?.trim() || null; + const [room, setRoom] = useState(null); + const [draft, setDraft] = useState(''); + const [joinName, setJoinName] = useState(''); + const [busy, setBusy] = useState(false); + const scrollRef = useRef(null); + + const loadJoined = useCallback(async () => { + try { + setJoined(await client.get('/soulseek/api/v0/rooms/joined')); + } catch { + /* status panel surfaces connection errors */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadAvailable = useCallback(async () => { + try { + setAvailable(await client.get('/soulseek/api/v0/rooms/available')); + } catch { + /* ignore */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadJoined(); + loadAvailable(); + }, [loadJoined, loadAvailable]); + + // Poll the selected room's transcript + user list while it's open. + useEffect(() => { + if (!selected) { + setRoom(null); + return; + } + let cancelled = false; + const tick = () => + client + .get(`/soulseek/api/v0/rooms/joined/${encodeURIComponent(selected)}`) + .then((r) => !cancelled && setRoom(r)) + .catch(() => {}); + tick(); + const timer = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + // useClient() is a fresh object each render — re-poll only when the selected room changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + + const messages = room?.messages ?? []; + // Stick to the bottom as new messages arrive / when switching rooms. + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages.length, selected]); + + const notJoined = useMemo(() => { + const have = new Set(joined); + return available + .filter((r) => !have.has(r.name)) + .sort((a, b) => b.userCount - a.userCount) + .slice(0, AVAILABLE_LIMIT); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [available, joined]); + + const join = async (name: string) => { + const target = name.trim(); + if (!target || busy) return; + setBusy(true); + try { + await client.post('/soulseek/api/v0/rooms/joined', target); + setJoinName(''); + setJoined((prev) => (prev.includes(target) ? prev : [...prev, target])); + // Joins the room *and then* opens it, so this stays a button — a link cannot express the "and then". + setParams((prev) => { + const next = new URLSearchParams(prev); + next.set(ROOM_PARAM, target); + return next; + }); + toast.success(`Joined ${target}`); + } catch (err) { + toast.error(`Couldn't join ${target}: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + }; + + const leave = async (name: string) => { + try { + await client.delete(`/soulseek/api/v0/rooms/joined/${encodeURIComponent(name)}`); + setJoined((prev) => prev.filter((n) => n !== name)); + // Leaving the open room leaves nothing open. `replace`, because a room you are no longer in is not + // somewhere Back should return you to. + if (name === selected) { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete(ROOM_PARAM); + return next; + }, + { replace: true }, + ); + } + } catch (err) { + toast.error(`Couldn't leave ${name}: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + const send = async () => { + const text = draft.trim(); + if (!text || !selected) return; + setDraft(''); + try { + await client.post(`/soulseek/api/v0/rooms/joined/${encodeURIComponent(selected)}/messages`, text); + } catch (err) { + setDraft(text); + toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + return ( +
+ {/* Room rail */} +
+
+ Joined + +
+
+ {joined.length === 0 &&

No rooms joined yet.

} + {joined.map((name) => { + const active = name === selected; + return ( +
+ {/* The leave button is a sibling, never nested inside the anchor. */} + + + {name} + + +
+ ); + })} +
+ + {/* Join a room */} +
+
{ + ev.preventDefault(); + join(joinName); + }} + className="flex items-center gap-1.5" + > + setJoinName(ev.target.value)} + list="slskd-available-rooms" + placeholder="Join a room…" + className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50" + /> + + {notJoined.map((r) => ( + + ))} + + +
+
+
+ + {/* Transcript + composer */} +
+ {!selected ? ( +
+ +

+ {joined.length === 0 ? 'Join a room to start chatting.' : 'Pick a room to read it.'} +

+
+ ) : ( + <> +
+ + {selected} + + {room?.users?.length ?? 0} + +
+ +
+ {messages.length === 0 ? ( +

No messages yet.

+ ) : ( +
+ {messages.map((m, i) => ( +
+ {formatClock(m.timestamp)} + {m.username} + {m.message} +
+ ))} +
+ )} +
+ +
{ + ev.preventDefault(); + send(); + }} + className="flex shrink-0 items-center gap-2 border-t p-3" + > + setDraft(ev.target.value)} + placeholder={`Message ${selected}…`} + className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> + +
+ + )} +
+
+ ); +}; diff --git a/web/SoulseekSystem.tsx b/web/SoulseekSystem.tsx new file mode 100644 index 0000000..158c69e --- /dev/null +++ b/web/SoulseekSystem.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; +import { Server, Radio, Plug, PlugZap, RefreshCw, Package, Loader2 } from 'lucide-react'; +import type { SlskdApplication, SlskdServerState } from './shared'; + +// System panel — connection health and controls for the slskd daemon. GET /application carries the +// server block (state/address/username) and version info; PUT /server connects to Soulseek, DELETE +// /server disconnects. Basic version: a connection card with connect/disconnect, and a version card. + +const POLL_MS = 3000; + +export const SoulseekSystem = () => { + const client = useClient(); + const [app, setApp] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + setApp(await client.get('/soulseek/api/v0/application')); + } catch { + /* connection card will show the stale/empty state */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + refresh(); + const timer = setInterval(refresh, POLL_MS); + return () => clearInterval(timer); + }, [refresh]); + + const server: SlskdServerState | undefined = app?.server; + const connected = server?.isConnected ?? false; + + const connect = async () => { + setBusy(true); + try { + await client.put('/soulseek/api/v0/server'); + toast.success('Connecting to Soulseek…'); + await refresh(); + } catch (err) { + toast.error(`Connect failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + }; + + const disconnect = async () => { + setBusy(true); + try { + await client.delete('/soulseek/api/v0/server'); + toast.success('Disconnected from Soulseek.'); + await refresh(); + } catch (err) { + toast.error(`Disconnect failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + }; + + const version = app?.version; + + return ( +
+
+
+ + System + +
+ + {/* Connection */} +
+
+ + +
+
{server?.state ?? 'Unknown'}
+ {server?.address &&
{server.address}
} +
+ {server?.username && {server.username}} +
+
+ + +
+
+ + {/* Version */} +
+
+ + Version +
+
+ + +
+ {version?.isUpdateAvailable ? ( + + Update available + + ) : ( + Up to date + )} +
+
+
+
+
+ ); +}; + +type FieldProps = { label: string; value: string }; + +const Field = ({ label, value }: FieldProps) => ( +
+ {label} + {value} +
+); diff --git a/web/SoulseekTransfers.tsx b/web/SoulseekTransfers.tsx new file mode 100644 index 0000000..318e5cd --- /dev/null +++ b/web/SoulseekTransfers.tsx @@ -0,0 +1,496 @@ +import type { ReactNode } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { toast } from 'sonner'; +import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react'; +import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards'; +import { UserMenu } from './UserMenu'; +import { + SLSKD_REFRESH_CHANNEL, + basename, + folderLabel, + formatSize, + formatSpeed, + transferPhase, + toneSolid, + PHASE_TONE, + type SlskdApplication, + type SlskdDownloadUser, + type SlskdTransfer, + type TransferPhase, +} from './shared'; + +// Right panel of the /soulseek workspace — the connection status strip plus the live download queue. +// Downloads are polled from slskd (GET /transfers/downloads, nested user → directories → files) and +// presented the same way as search results: a collapsible card per user, files grouped by folder. +// Enqueuing from the search panel bumps the refresh channel so the list updates without waiting for the +// next poll tick. + +const POLL_MS = 1500; + +type Row = SlskdTransfer & { phase: TransferPhase }; +type DlDir = { directory: string; label: string; files: Row[]; size: number }; +type DlUser = { username: string; dirs: DlDir[]; counts: Record; total: number }; + +const PHASE_ORDER: Record = { downloading: 0, queued: 1, failed: 2, done: 3 }; +const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done']; + +const PHASE_LABEL: Record = { + downloading: 'Downloading', + queued: 'Queued', + done: 'Done', + failed: 'Failed', +}; + +const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed'; + +// Group raw download users → dirs → files, keeping only files whose phase passes `keep`. Users/dirs +// that end up empty are dropped, so passing a done/not-done predicate splits the queue into sections. +const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boolean): DlUser[] => { + const out: DlUser[] = []; + for (const user of users) { + const dirs: DlDir[] = []; + for (const dir of user.directories ?? []) { + const files: Row[] = (dir.files ?? []) + .map((f) => ({ ...f, phase: transferPhase(f.state) })) + .filter((f) => keep(f.phase)); + if (files.length === 0) continue; + files.sort( + (a, b) => + PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)), + ); + dirs.push({ + directory: dir.directory, + label: folderLabel(dir.directory), + files, + size: files.reduce((n, f) => n + f.size, 0), + }); + } + if (dirs.length === 0) continue; + dirs.sort((a, b) => a.directory.localeCompare(b.directory)); + const counts: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; + let total = 0; + for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++); + out.push({ username: user.username, dirs, counts, total }); + } + // Active users first, then alphabetical. + out.sort( + (a, b) => + Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username), + ); + return out; +}; + +export const SoulseekTransfers = () => { + const client = useClient(); + const [refresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); + + const [app, setApp] = useState(null); + const [statusError, setStatusError] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + const [data, setData] = useState([]); + // Users are open by default; collapsing a live-updating list is opt-in so new users appear expanded. + const [collapsed, setCollapsed] = useState>(new Set()); + + // Two sections: everything still in flight (downloading / queued / failed) on top, finished + // (succeeded) collected at the bottom. A user with a mix appears in both. + const active = useMemo(() => group(data, (p) => p !== 'done'), [data]); + const completed = useMemo(() => group(data, (p) => p === 'done'), [data]); + const doneCount = useMemo(() => completed.reduce((n, u) => n + u.total, 0), [completed]); + + // Queue-wide phase tallies drive the bulk toolbar. Cancelled transfers collapse to `failed`, so the + // three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done. + const totals = useMemo(() => { + const t: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; + for (const u of data) + for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++; + return t; + }, [data]); + const inFlight = totals.downloading + totals.queued; + + const loadStatus = useCallback(() => { + setStatusLoading(true); + setStatusError(null); + client + .get('/soulseek/api/v0/application') + .then((d) => setApp(d)) + .catch((err) => setStatusError(err instanceof Error ? err.message : String(err))) + .finally(() => setStatusLoading(false)); + // useClient() is a fresh object each render — keep this callback stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadStatus(); + }, [loadStatus]); + + // Poll the download queue; also refetch immediately when the search panel bumps the refresh nonce. + useEffect(() => { + let cancelled = false; + const tick = () => + client + .get('/soulseek/api/v0/transfers/downloads') + .then((u) => !cancelled && setData(u)) + .catch(() => {}); + tick(); + const timer = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + // useClient() is a fresh object each render — poll on the refresh nonce only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refresh]); + + // Collapse keys are section-qualified ("a:" / "c:") so the same peer can be open in the + // active section and collapsed in completed independently. + const toggle = (key: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const dropIds = (ids: Set) => + setData((prev) => + prev.map((u) => ({ + ...u, + directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })), + })), + ); + + // For an active transfer this cancels it; for a finished/failed one it clears it from the queue. + const remove = async (row: Row) => { + const done = isRemovable(row.phase); + try { + await client.delete( + `/soulseek/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`, + ); + dropIds(new Set([row.id])); + } catch { + /* next poll reconciles */ + } + }; + + const clearCompleted = async (user: DlUser) => { + const finished = user.dirs.flatMap((d) => d.files).filter((f) => isRemovable(f.phase)); + if (finished.length === 0) return; + await Promise.allSettled( + finished.map((f) => + client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(user.username)}/${f.id}?remove=true`), + ), + ); + dropIds(new Set(finished.map((f) => f.id))); + }; + + const clearAllCompleted = async () => { + const finished = completed.flatMap((u) => + u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))), + ); + if (finished.length === 0) return; + await Promise.allSettled( + finished.map((f) => + client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(f.username)}/${f.id}?remove=true`), + ), + ); + dropIds(new Set(finished.map((f) => f.id))); + }; + + // Cancel every in-flight transfer (no ?remove — they become cancelled/failed and the poll reconciles). + const cancelAll = async () => { + const targets: { username: string; id: string }[] = []; + for (const u of data) + for (const d of u.directories ?? []) + for (const f of d.files ?? []) { + const p = transferPhase(f.state); + if (p === 'downloading' || p === 'queued') targets.push({ username: u.username, id: f.id }); + } + if (targets.length === 0) return; + await Promise.allSettled( + targets.map((t) => client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(t.username)}/${t.id}`)), + ); + }; + + // Re-enqueue every errored/cancelled transfer, grouped into one POST per peer. + const retryErrored = async () => { + const byUser = new Map(); + for (const u of data) + for (const d of u.directories ?? []) + for (const f of d.files ?? []) + if (transferPhase(f.state) === 'failed') { + const files = byUser.get(u.username) ?? []; + files.push({ filename: f.filename, size: f.size }); + byUser.set(u.username, files); + } + if (byUser.size === 0) return; + const n = [...byUser.values()].reduce((s, a) => s + a.length, 0); + try { + await Promise.all( + [...byUser].map(([username, files]) => + client.post(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(username)}`, files), + ), + ); + toast.success(n === 1 ? 'Retrying 1 download' : `Retrying ${n} downloads`); + } catch (err) { + toast.error(`Retry failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + // Force slskd to re-request this download's place in the peer's queue, then patch the fresh value in. + const refreshPosition = async (row: Row) => { + try { + const t = await client.get( + `/soulseek/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}/position`, + ); + setData((prev) => + prev.map((u) => + u.username !== row.username + ? u + : { + ...u, + directories: (u.directories ?? []).map((d) => ({ + ...d, + files: (d.files ?? []).map((f) => (f.id === row.id ? { ...f, placeInQueue: t.placeInQueue } : f)), + })), + }, + ), + ); + toast.success(t.placeInQueue ? `Place in queue: #${t.placeInQueue}` : 'Queue position refreshed'); + } catch (err) { + toast.error(`Couldn't fetch position: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + const server = app?.server; + const connected = server?.isConnected ?? false; + + return ( +
+
+
+ + {statusLoading && !app ? ( + Checking slskd… + ) : statusError ? ( + slskd unreachable: {statusError} + ) : ( + <> + + {server?.state ?? 'unknown'} + {server?.address && · {server.address}} + {app?.version?.current && ( + · v{app.version.current} + )} + + )} +
+ +
+ + {(active.length > 0 || completed.length > 0) && ( +
+ } + label="Retry errored" + count={totals.failed} + onClick={retryErrored} + /> + } label="Cancel all" count={inFlight} onClick={cancelAll} /> + } + label="Remove completed" + count={totals.done} + onClick={clearAllCompleted} + /> +
+ )} + +
+ {active.length === 0 && completed.length === 0 ? ( +

No downloads yet. Search and queue a file to see it here.

+ ) : ( +
+ {active.length > 0 && ( +
+
+ Active ({active.length}) +
+ {active.map((user) => ( + toggle(`a:${user.username}`)} + onRemove={remove} + onPosition={refreshPosition} + onClearCompleted={() => clearCompleted(user)} + /> + ))} +
+ )} + {completed.length > 0 && ( +
+
+
+ Completed ({doneCount}) +
+ +
+ {completed.map((user) => ( + toggle(`c:${user.username}`)} + onRemove={remove} + onPosition={refreshPosition} + onClearCompleted={() => clearCompleted(user)} + /> + ))} +
+ )} +
+ )} +
+
+ ); +}; + +type BulkButtonProps = { icon: ReactNode; label: string; count: number; onClick: () => void }; + +const BulkButton = ({ icon, label, count, onClick }: BulkButtonProps) => ( + +); + +type UserCardProps = { + user: DlUser; + open: boolean; + onToggle: () => void; + onRemove: (row: Row) => void; + onPosition: (row: Row) => void; + onClearCompleted: () => void; +}; + +const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted }: UserCardProps) => { + const finished = user.counts.done + user.counts.failed; + const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => ( + + + {user.counts[p]} + + )); + return ( + + } meta={meta} /> + {open && ( + + {user.dirs.map((dir) => ( + + ))} + {finished > 0 && ( + + )} + + )} + + ); +}; + +type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: (row: Row) => void }; + +const FolderBlock = ({ dir, onRemove, onPosition }: FolderBlockProps) => { + const [open, setOpen] = useState(true); + return ( + + setOpen((v) => !v)} + icon={} + label={dir.label} + title={dir.directory} + meta={`${dir.files.length} · ${formatSize(dir.size)}`} + /> + {open && ( + + {dir.files.map((row) => { + const dot = toneSolid[PHASE_TONE[row.phase]]; + const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete))); + const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : ''; + return ( +
+
+ + {basename(row.filename)} + +
+
+
+
+
+ {row.phase === 'queued' ? ( + + ) : ( + {PHASE_LABEL[row.phase]} + )} + {row.size > 0 && · {formatSize(row.size)}} + {speed && · {speed}} +
+
+ ); + })} + + )} + + ); +}; diff --git a/web/SoulseekUploads.tsx b/web/SoulseekUploads.tsx new file mode 100644 index 0000000..acb7756 --- /dev/null +++ b/web/SoulseekUploads.tsx @@ -0,0 +1,229 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useClient } from 'hooks/useClient'; +import { RefreshCw, X, Folder, ArrowUpFromLine } from 'lucide-react'; +import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards'; +import { + basename, + folderLabel, + formatSize, + formatSpeed, + transferPhase, + toneSolid, + PHASE_TONE, + type SlskdTransfer, + type SlskdTransferUser, + type TransferPhase, +} from './shared'; + +// Uploads panel — the mirror image of Downloads: what peers are pulling from us. GET /transfers/uploads +// returns the same user → directories → files shape, so we group and render it the same way (collapsible +// card per peer, files by folder, a progress bar per file). Basic version: one flat list, per-row +// cancel/clear; no retry/queue-position controls (those are download-side concerns). + +const POLL_MS = 1500; + +type Row = SlskdTransfer & { phase: TransferPhase }; +type UpDir = { directory: string; label: string; files: Row[]; size: number }; +type UpUser = { username: string; dirs: UpDir[]; counts: Record; total: number }; + +const PHASE_ORDER: Record = { downloading: 0, queued: 1, failed: 2, done: 3 }; +const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done']; + +const PHASE_LABEL: Record = { + downloading: 'Uploading', + queued: 'Queued', + done: 'Done', + failed: 'Failed', +}; + +const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed'; + +const group = (users: SlskdTransferUser[]): UpUser[] => { + const out: UpUser[] = []; + for (const user of users) { + const dirs: UpDir[] = []; + for (const dir of user.directories ?? []) { + const files: Row[] = (dir.files ?? []).map((f) => ({ ...f, phase: transferPhase(f.state) })); + if (files.length === 0) continue; + files.sort( + (a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)), + ); + dirs.push({ + directory: dir.directory, + label: folderLabel(dir.directory), + files, + size: files.reduce((n, f) => n + f.size, 0), + }); + } + if (dirs.length === 0) continue; + dirs.sort((a, b) => a.directory.localeCompare(b.directory)); + const counts: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; + let total = 0; + for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++); + out.push({ username: user.username, dirs, counts, total }); + } + out.sort( + (a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username), + ); + return out; +}; + +export const SoulseekUploads = () => { + const client = useClient(); + const [data, setData] = useState([]); + const [loaded, setLoaded] = useState(false); + const [collapsed, setCollapsed] = useState>(new Set()); + + const users = useMemo(() => group(data), [data]); + + useEffect(() => { + let cancelled = false; + const tick = () => + client + .get('/soulseek/api/v0/transfers/uploads') + .then((u) => !cancelled && (setData(u), setLoaded(true))) + .catch(() => {}); + tick(); + const timer = setInterval(tick, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + // useClient() is a fresh object each render — poll on a stable interval only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toggle = (name: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + + const dropIds = (ids: Set) => + setData((prev) => + prev.map((u) => ({ + ...u, + directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })), + })), + ); + + // Abort an in-flight upload, or clear a finished/failed one from the queue. + const remove = async (row: Row) => { + const done = isRemovable(row.phase); + try { + await client.delete( + `/soulseek/api/v0/transfers/uploads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`, + ); + dropIds(new Set([row.id])); + } catch { + /* next poll reconciles */ + } + }; + + return ( +
+
+ + Uploads + · {users.length} peer{users.length === 1 ? '' : 's'} + {!loaded && } +
+ +
+ {users.length === 0 ? ( +

+ {loaded ? 'No uploads. When a peer downloads a shared file it shows up here.' : 'Loading uploads…'} +

+ ) : ( +
+ {users.map((user) => ( + toggle(user.username)} + onRemove={remove} + /> + ))} +
+ )} +
+
+ ); +}; + +type UserCardProps = { user: UpUser; open: boolean; onToggle: () => void; onRemove: (row: Row) => void }; + +const UserCard = ({ user, open, onToggle, onRemove }: UserCardProps) => { + const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => ( + + + {user.counts[p]} + + )); + return ( + + + {open && ( + + {user.dirs.map((dir) => ( + + ))} + + )} + + ); +}; + +type FolderBlockProps = { dir: UpDir; onRemove: (row: Row) => void }; + +const FolderBlock = ({ dir, onRemove }: FolderBlockProps) => { + const [open, setOpen] = useState(true); + return ( + + setOpen((v) => !v)} + icon={} + label={dir.label} + title={dir.directory} + meta={`${dir.files.length} · ${formatSize(dir.size)}`} + /> + {open && ( + + {dir.files.map((row) => { + const dot = toneSolid[PHASE_TONE[row.phase]]; + const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete))); + const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : ''; + return ( +
+
+ + {basename(row.filename)} + +
+
+
+
+
+ {PHASE_LABEL[row.phase]} + {row.size > 0 && · {formatSize(row.size)}} + {speed && · {speed}} +
+
+ ); + })} + + )} + + ); +}; diff --git a/web/SoulseekUsers.tsx b/web/SoulseekUsers.tsx new file mode 100644 index 0000000..a72ec29 --- /dev/null +++ b/web/SoulseekUsers.tsx @@ -0,0 +1,235 @@ +import { toneText } from '@/components/Data/tone'; +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router'; +import { toast } from 'sonner'; +import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react'; +import { useSoulseekFavorites } from './useSoulseekFavorites'; +import { useSoulseekUser } from './useSoulseekUser'; +import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse'; +import { SharesBrowser, BrowseStateChip } from './SharesBrowser'; +import { USER_PARAM } from './shared'; + +// Users panel — look up a peer: their presence (online/away/offline) and profile info (description, +// upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come +// from Officer's own cache instead, via — see that file for why it isn't browsed live. +// +// This is also where the workspace's peer actions land: the username dropdown in search results and +// downloads links here with `?user=`, which is the whole mechanism — a peer profile is now +// linkable and survives a reload. +// Favourites — Officer's own data, since slskd has no such concept — get their own section at the top, +// which also serves as this panel's landing content before any lookup. + +const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof CircleCheck } => { + switch ((p ?? '').toLowerCase()) { + case 'online': + return { label: 'Online', dot: toneText.success, icon: CircleCheck }; + case 'away': + return { label: 'Away', dot: toneText.warning, icon: Clock }; + default: + return { label: 'Offline', dot: toneText.neutral, icon: CircleSlash }; + } +}; + +export const SoulseekUsers = () => { + const [params, setParams] = useSearchParams(); + const selected = params.get(USER_PARAM)?.trim() ?? ''; + const [query, setQuery] = useState(selected); + const { favorites, isFavorite, toggle } = useSoulseekFavorites(); + const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots(); + + // Selecting a peer is now just naming one — the query owns the fetching, and its cache owns the answer. + const user = useSoulseekUser(selected); + const peer = user.data ?? null; + + // Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically — + // that's a multi-minute server-side job, so it stays an explicit click in the shares card. + const select = (name: string) => { + const username = name.trim(); + if (!username) return; + setParams((prev) => { + const next = new URLSearchParams(prev); + next.set(USER_PARAM, username); + return next; + }); + }; + + // Arriving by link (or Back) should show the name in the box that produced it. + useEffect(() => { + if (selected) setQuery(selected); + }, [selected]); + + useEffect(() => { + if (user.error) toast.error(user.error.message); + }, [user.error]); + + const pres = presenceStyle(peer?.status?.presence); + const PresIcon = pres.icon; + // Only a peer we have nothing cached for is worth showing a pending state for. + const pending = !!selected && user.isPending; + + return ( +
+
+ +
{ + ev.preventDefault(); + select(query); + }} + className="flex flex-1 items-center gap-2" + > + setQuery(ev.target.value)} + placeholder="Look up a username…" + className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> + +
+
+ +
+
+ {/* Favorites — Officer's own data (slskd has no such concept). Always present, so the section + doubles as the panel's landing content before any lookup. */} +
+
+ + Favorites + {favorites.length > 0 && · {favorites.length}} +
+ {favorites.length === 0 ? ( +

+ No favorites yet — add one from a peer's profile below, or from the username menu in search results and + downloads. +

+ ) : ( +
+ {favorites.map((u) => { + const current = selected === u; + return ( +
+ + + {!snapshotOf(u) && ( + + )} + +
+ ); + })} +
+ )} +
+ + {!selected ? ( +
+ +

Enter a Soulseek username to see their profile and shares.

+
+ ) : ( + <> + {/* Profile. Keyed off the selected name, not the loaded peer, so a lookup already in cache + paints instantly and a fresh one fills in around a card that's already on screen. */} +
+
+
+ {selected.charAt(0).toUpperCase()} +
+
+
{selected}
+ {pending ? ( +
+ + Checking… +
+ ) : ( +
+ + {pres.label} + {peer?.status?.isPrivileged && · privileged} +
+ )} +
+ +
+ + {peer?.info && ( + <> +
+ + + +
+ {peer.info.description && ( +

+ {peer.info.description} +

+ )} + + )} +
+ + + + )} +
+
+
+ ); +}; + +type StatProps = { label: string; value: string | number; accent?: string }; + +const Stat = ({ label, value, accent = 'text-foreground' }: StatProps) => ( +
+ {label} + {value} +
+); diff --git a/web/SoulseekView.tsx b/web/SoulseekView.tsx new file mode 100644 index 0000000..e4eff87 --- /dev/null +++ b/web/SoulseekView.tsx @@ -0,0 +1,66 @@ +import { Construction } from 'lucide-react'; +import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared'; +import { SearchView } from './SearchView'; +import { SoulseekTransfers } from './SoulseekTransfers'; +import { SoulseekUploads } from './SoulseekUploads'; +import { SoulseekRooms } from './SoulseekRooms'; +import { SoulseekDashboard } from './SoulseekDashboard'; +import { SoulseekChat } from './SoulseekChat'; +import { SoulseekUsers } from './SoulseekUsers'; +import { SoulseekSystem } from './SoulseekSystem'; +import { SoulseekConnection } from './SoulseekConnection'; +import { useServiceConnection } from 'officerdev'; + +// Right panel of the /soulseek workspace — renders the UI for the section the URL names. Content +// zoom used to live here; it is now a framework-level per-panel control in PanelSlot. + +const Placeholder = ({ id }: { id: SoulseekSectionId }) => { + const label = SOULSEEK_SECTIONS.find((s) => s.id === id)?.label ?? id; + return ( +
+
+ +
+
+
{label}
+
Coming soon
+
+
+ ); +}; + +const sectionView = (section: SoulseekSectionId) => { + switch (section) { + case 'dashboard': + return ; + case 'search': + return ; + case 'downloads': + return ; + case 'uploads': + return ; + case 'rooms': + return ; + case 'chat': + return ; + case 'users': + return ; + case 'system': + return ; + case 'connection': + return ; + default: + return ; + } +}; + +export const SoulseekView = () => { + const section = useSoulseekSection(); + const { data: connection, isLoading } = useServiceConnection('soulseek'); + + // Nothing connected yet: the setup form takes over every section, because none of them can do anything + // without a daemon. + if (!isLoading && !connection?.configured) return ; + + return
{sectionView(section)}
; +}; diff --git a/web/UserMenu.tsx b/web/UserMenu.tsx new file mode 100644 index 0000000..cd3c2ca --- /dev/null +++ b/web/UserMenu.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router'; +import { toast } from 'sonner'; +import { FolderOpen, Star, StarOff } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { useSoulseekFavorites } from './useSoulseekFavorites'; +import { soulseekUserPath } from './shared'; + +// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace +// and act on that peer. Opening a peer is a real link to /soulseek/users?user= — the Users section +// still owns the lookup and the cached share tree, it just reads who from the URL. Favouriting is a +// mutation, so it stays a menu item that acts rather than navigates. + +type UserMenuProps = { username: string }; + +export const UserMenu = ({ username }: UserMenuProps) => { + const { isFavorite, toggle } = useSoulseekFavorites(); + const favorited = isFavorite(username); + + const toggleFavorite = () => { + toggle(username); + toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`); + }; + + return ( + + + + + + {username} + + + + + View profile & shares + + + + {favorited ? : } + {favorited ? 'Remove from favorites' : 'Add user to favorites'} + + + + ); +}; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..e5f8171 --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,11 @@ +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/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..e3cf41c --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,14 @@ +import type { AppRegistryMeta } from 'officerdev'; +import { PanelLeft, LayoutGrid } from 'lucide-react'; +import { SoulseekNav } from './SoulseekNav'; +import { SoulseekView } from './SoulseekView'; + +// `panels.ts`, not `.tsx` — discovery checks for that exact name. A section nav on the left and the view +// on the right, both reading `:section` from the URL rather than being told by each other. +// +// `availableOnPanel: false` because the screen renders the workspace `locked`: these are not swappable, +// and offering them in a picker would be a lie. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'soulseek-nav', name: 'Soulseek', icon: PanelLeft, component: SoulseekNav, availableOnPanel: false }, + { key: 'soulseek-view', name: 'Soulseek', icon: LayoutGrid, component: SoulseekView, availableOnPanel: false }, +]; diff --git a/web/shared.ts b/web/shared.ts new file mode 100644 index 0000000..6bf48f4 --- /dev/null +++ b/web/shared.ts @@ -0,0 +1,411 @@ +// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/soulseek +// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here +// is a refresh signal, which is what channels are for. + +import type { Tone } from '@/components/Data/tone'; +import { useParams } from 'react-router'; + +// Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers +// panel refetches immediately instead of waiting for its next poll tick. +export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh'; + +export type SlskdApplication = { + version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean }; + server?: { state?: string; address?: string; isConnected?: boolean }; +}; + +export type SlskdSearch = { + id: string; + isComplete: boolean; + state: string; + fileCount: number; + responseCount: number; +}; + +export type SlskdFile = { + filename: string; + size: number; + extension?: string; + isLocked?: boolean; + bitRate?: number; + length?: number; +}; + +export type SlskdResponse = { + username: string; + fileCount: number; + files: SlskdFile[]; + hasFreeUploadSlot: boolean; + queueLength: number; + uploadSpeed: number; +}; + +// One flat, rankable search result (a single file from a single peer). +export type ResultRow = { + username: string; + filename: string; + size: number; + extension: string; + isLocked: boolean; + bitRate?: number; + hasFreeUploadSlot: boolean; + queueLength: number; + uploadSpeed: number; +}; + +// slskd presents results grouped by responder (user) → folder → files. We mirror that shape. +export type ResultFile = { + filename: string; // full remote path (slskd needs this verbatim to enqueue) + name: string; // basename for display + size: number; + extension: string; + bitRate?: number; + length?: number; + isLocked: boolean; +}; +export type ResultFolder = { path: string; label: string; files: ResultFile[]; size: number }; +export type ResultUser = { + username: string; + hasFreeUploadSlot: boolean; + queueLength: number; + uploadSpeed: number; + fileCount: number; + folders: ResultFolder[]; +}; + +// Group a search's responses into slskd's user → folder → file layout, best peers first. +export const groupResponses = (responses: SlskdResponse[]): ResultUser[] => { + const users: ResultUser[] = []; + for (const resp of responses) { + const byFolder = new Map(); + for (const f of resp.files ?? []) { + const parts = f.filename.split(/[\\/]/); + const name = parts.pop() ?? f.filename; + const path = parts.join('\\') || '\\'; + const file: ResultFile = { + filename: f.filename, + name, + size: f.size, + extension: extOf(f), + bitRate: f.bitRate, + length: f.length, + isLocked: !!f.isLocked, + }; + const arr = byFolder.get(path); + if (arr) arr.push(file); + else byFolder.set(path, [file]); + } + const folders: ResultFolder[] = [...byFolder.entries()] + .map(([path, files]) => { + files.sort((a, b) => a.name.localeCompare(b.name)); + return { path, label: folderLabel(path), files, size: files.reduce((n, f) => n + f.size, 0) }; + }) + .sort((a, b) => a.path.localeCompare(b.path)); + users.push({ + username: resp.username, + hasFreeUploadSlot: resp.hasFreeUploadSlot, + queueLength: resp.queueLength, + uploadSpeed: resp.uploadSpeed, + fileCount: resp.fileCount ?? resp.files?.length ?? 0, + folders, + }); + } + // Best peer first: free slot, then faster upload, then more files. + users.sort( + (a, b) => + Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) || + b.uploadSpeed - a.uploadSpeed || + b.fileCount - a.fileCount, + ); + return users; +}; + +// A download transfer. GET /transfers/downloads nests these as user → directories → files. +export type SlskdTransfer = { + id: string; + username: string; + direction: string; + filename: string; + size: number; + state: string; + bytesTransferred: number; + averageSpeed: number; + percentComplete: number; + placeInQueue?: number; +}; +export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] }; +export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] }; +// Uploads (GET /transfers/uploads) use the same user → directories → files shape as downloads. +export type SlskdTransferUser = SlskdDownloadUser; + +// Private conversations. GET /conversations lists them (one per peer); GET /conversations/{user}/messages +// returns that thread. A message's direction ('Out' = sent by us) drives self-alignment in the UI. +export type SlskdPrivateMessage = { + id: number; + timestamp: string; + username: string; + direction: string; // 'In' | 'Out' + message: string; + isAcknowledged?: boolean; +}; +export type SlskdConversation = { + username: string; + isActive?: boolean; + unAcknowledgedMessageCount?: number; + hasUnAcknowledgedMessages?: boolean; + messages?: SlskdPrivateMessage[]; +}; + +// A peer, as seen through the Users section. GET /users/{u}/info + /status; /browse returns their shares. +export type SlskdUserInfo = { + description?: string; + hasFreeUploadSlot?: boolean; + hasPicture?: boolean; + queueLength?: number; + uploadSlots?: number; +}; +export type SlskdUserStatus = { + isPrivileged?: boolean; + presence?: string; // 'Offline' | 'Away' | 'Online' +}; +export type SlskdBrowseDirectory = { name: string; fileCount: number; files?: SlskdFile[] }; +// GET /users/{u}/browse wraps the share tree rather than returning a bare array, and inlines every +// file of every folder in one shot (real peers hit 18k folders / tens of MB). Note that a browsed +// file's `filename` is only the basename — unlike search results, where it's the full remote path — +// so enqueuing one means rejoining it to its directory name with a backslash. +export type SlskdBrowseResponse = { + directories: SlskdBrowseDirectory[]; + directoryCount: number; + lockedDirectories: SlskdBrowseDirectory[]; + lockedDirectoryCount: number; +}; + +// ── Cached share trees (Officer's own, from the sidecar's /_officer/browse routes) ── +// +// A live browse is one blocking slskd call carrying every file of every folder — 59 MB / 18k folders / +// 284k files for a real peer, thrown away the moment you navigate. So the sidecar fetches it in the +// background into Postgres and the UI reads paged slices. Timestamps arrive as ISO strings. + +export type SoulseekBrowseStatus = 'pending' | 'ready' | 'failed'; +export type SoulseekBrowseSnapshot = { + username: string; + status: SoulseekBrowseStatus; + error: string | null; + directoryCount: number; + fileCount: number; + totalSize: number; + startedAt: string; + completedAt: string | null; +}; +// A node of the stored tree. The sidecar links slskd's flat backslash paths up at ingest, so `label` is +// already just the last segment and the subtree rollups are already summed — this side never derives +// either, which is the point: a collapsed row can show what's underneath it without fetching it. +export type SoulseekBrowseNode = { + id: number; + name: string; + parentPath: string | null; + label: string; + depth: number; + childCount: number; + fileCount: number; + totalSize: number; + subtreeFileCount: number; + subtreeSize: number; +}; +export type SoulseekBrowseLevel = { nodes: SoulseekBrowseNode[]; total: number }; +/** A filtered tree: the matches plus every ancestor holding them up, so the shape survives filtering. */ +export type SoulseekBrowseSearch = { + nodes: SoulseekBrowseNode[]; + matched: string[]; + total: number; + truncated: boolean; +}; +// Browse carries no bitrate/duration (real peers send empty `attributes`), so unlike a search result +// there's no quality metadata to show — just name, size, extension. +export type SoulseekBrowsedFile = { name: string; size: number; extension: string }; + +// Server connection state (GET /server, and the server block of GET /application). +export type SlskdServerState = { address?: string; state?: string; isConnected?: boolean; username?: string }; + +// Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest. +export type SlskdRoomUser = { username: string }; +export type SlskdRoomMessage = { + timestamp: string; + username: string; + message: string; + roomName?: string; + self?: boolean; +}; +export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] }; +export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean }; + +// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and +// neither publishes to the other. Mirrors slskd's own top-menu sections. +export type SoulseekSectionId = + | 'dashboard' + | 'search' + | 'downloads' + | 'uploads' + | 'rooms' + | 'chat' + | 'users' + | 'system' + | 'connection'; +export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'search', label: 'Search' }, + { id: 'downloads', label: 'Downloads' }, + { id: 'uploads', label: 'Uploads' }, + { id: 'rooms', label: 'Rooms' }, + { id: 'chat', label: 'Chat' }, + { id: 'users', label: 'Users' }, + { id: 'system', label: 'System' }, + { id: 'connection', label: 'Connection' }, +]; + +/** Where /soulseek lands, and where an unrecognised section redirects to. */ +export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard'; + +export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId => + SOULSEEK_SECTIONS.some((s) => s.id === value); + +export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`; + +// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before +// that lands. +export const useSoulseekSection = (): SoulseekSectionId => { + const { section } = useParams(); + return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION; +}; + +// Which peer the Users section is showing — `/soulseek/users?user=`. +// +// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a +// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the +// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not +// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate. +export const USER_PARAM = 'user'; + +export const soulseekUserPath = (username: string) => + `${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`; + +// Which room the Rooms section is showing — `/soulseek/rooms?room=` — and which thread the Chat +// section is showing — `/soulseek/chat?peer=`. Two params rather than one because they are two +// sections: they never appear together, and a shared name would make `?thing=` mean different kinds of +// thing depending on the path. +// +// Both rails used to auto-select the first entry on load, which is why they held the selection locally: +// there was nowhere else to put an answer the user had not given. The bare section is now a real state — +// "nothing open" — and both panels already had the empty pane to say so. +export const ROOM_PARAM = 'room'; +export const PEER_PARAM = 'peer'; + +export const soulseekRoomPath = (name: string) => + `${soulseekSectionPath('rooms')}?${ROOM_PARAM}=${encodeURIComponent(name)}`; + +export const soulseekPeerPath = (username: string) => + `${soulseekSectionPath('chat')}?${PEER_PARAM}=${encodeURIComponent(username)}`; + +// A past search, as listed by GET /searches (no responses inlined). +// Which search's results are open — `/soulseek/search?search=`, read from the URL rather than held in a +// panel channel or local state. See docs/navigation-audit.md. +export const SEARCH_PARAM = 'search'; + +export type SlskdSearchSummary = { + id: string; + searchText: string; + startedAt: string; + endedAt?: string; + fileCount: number; + lockedFileCount: number; + responseCount: number; + isComplete: boolean; + state: string; +}; + +export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path; + +// Compact folder label for grouped views — the last two path segments (slskd paths use backslashes). +export const folderLabel = (path: string) => { + const segs = path.split(/[\\/]/).filter(Boolean); + return segs.slice(-2).join(' / ') || path; +}; + +// Compact "time ago" for search-history rows. +export const formatWhen = (iso?: string): string => { + if (!iso) return ''; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) return ''; + const s = Math.round((Date.now() - t) / 1000); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString(); +}; + +// Wall-clock HH:MM for chat message timestamps. +export const formatClock = (iso?: string): string => { + if (!iso) return ''; + const d = new Date(iso); + return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; +}; + +export const formatSize = (bytes: number) => { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +}; + +export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : ''); + +// Track length (seconds) → m:ss, as slskd shows it. +export const formatDuration = (sec?: number): string => { + if (!sec || sec < 0) return ''; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${String(s).padStart(2, '0')}`; +}; + +export const extOf = (file: SlskdFile) => + (file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase(); + +// slskd serialises TransferStates as a compound flag string ("Completed, Succeeded", "Queued, Remotely", +// "InProgress", "Requested", …). Collapse it to the one bucket we render. +export type TransferPhase = 'downloading' | 'queued' | 'done' | 'failed'; +export const transferPhase = (state: string): TransferPhase => { + const s = state.toLowerCase(); + if (s.includes('inprogress') || s.includes('initializing')) return 'downloading'; + if (s.includes('succeeded')) return 'done'; + if ( + s.includes('errored') || + s.includes('cancelled') || + s.includes('rejected') || + s.includes('timedout') || + s.includes('aborted') + ) + return 'failed'; + return 'queued'; // Requested / Queued / anything not yet resolved +}; + +// The one place a transfer phase becomes a colour. Transfers, uploads and the dashboard tiles all read it, +// so a phase cannot mean success in one panel and warning in the next. +export const PHASE_TONE: Record = { + downloading: 'info', + queued: 'warning', + done: 'success', + failed: 'danger', +}; + +// A solid dot or progress bar, which `toneFill` (a /15 tint) deliberately does not cover. +export const toneSolid: Record = { + neutral: 'bg-muted-foreground', + success: 'bg-success', + warning: 'bg-warning', + danger: 'bg-destructive', + info: 'bg-info', + busy: 'bg-busy', +}; diff --git a/web/useSoulseekBrowse.ts b/web/useSoulseekBrowse.ts new file mode 100644 index 0000000..ae6922e --- /dev/null +++ b/web/useSoulseekBrowse.ts @@ -0,0 +1,148 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useClient } from 'hooks/useClient'; +import type { SoulseekBrowseSnapshot, SoulseekBrowseLevel, SoulseekBrowseSearch, SoulseekBrowsedFile } from './shared'; + +// Client for the sidecar's cached share trees. The expensive work happens server-side and outlives the +// tab, so this hook only ever starts a fetch and polls for its status — it never pulls a whole tree. + +const SNAPSHOTS_KEY = ['soulseek', 'browse'] as const; +const EMPTY: SoulseekBrowseSnapshot[] = []; + +// While something is fetching we need to notice it finishing; otherwise this data barely changes. +const POLL_MS = 3000; + +/** + * Every peer's cache state in one request, so the favourites list can badge each row without N queries. + * Polls only while at least one fetch is in flight. + */ +export function useSoulseekBrowseSnapshots() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + + const { data } = useQuery({ + queryKey: SNAPSHOTS_KEY, + queryFn: () => get('/soulseek/_officer/browse'), + staleTime: 10_000, + refetchInterval: (query) => (query.state.data?.some((s) => s.status === 'pending') ? POLL_MS : false), + }); + + const snapshots = data ?? EMPTY; + const snapshotOf = (username: string) => snapshots.find((s) => s.username === username) ?? null; + + const start = useMutation({ + mutationFn: (username: string) => post(`/soulseek/_officer/browse/${encodeURIComponent(username)}`, {}), + // Flip to pending immediately so the row shows a spinner before the first poll lands. + onMutate: (username) => { + const prev = qc.getQueryData(SNAPSHOTS_KEY) ?? EMPTY; + const existing = prev.find((s) => s.username === username); + const pending: SoulseekBrowseSnapshot = { + ...(existing ?? { + username, + directoryCount: 0, + fileCount: 0, + totalSize: 0, + completedAt: null, + }), + status: 'pending', + error: null, + startedAt: new Date().toISOString(), + }; + qc.setQueryData( + SNAPSHOTS_KEY, + existing ? prev.map((s) => (s.username === username ? pending : s)) : [...prev, pending], + ); + return { prev }; + }, + onError: (_err, _username, ctx) => { + if (ctx?.prev) qc.setQueryData(SNAPSHOTS_KEY, ctx.prev); + }, + onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }), + }); + + const drop = useMutation({ + mutationFn: (username: string) => del(`/soulseek/_officer/browse/${encodeURIComponent(username)}`), + onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }), + }); + + return { + snapshots, + snapshotOf, + fetchShares: (username: string) => start.mutate(username), + dropShares: (username: string) => drop.mutate(username), + }; +} + +type LevelParams = { username: string | null; parent: string | null; limit: number; enabled?: boolean }; + +/** + * One level of a peer's tree — the children of `parent`, or the roots when it's null. Cached per level, + * so collapsing and reopening a folder is free, and expanding a shallow node never touches its subtree. + */ +export function useSoulseekBrowseLevel({ username, parent, limit, enabled = true }: LevelParams) { + const { get } = useClient(); + + return useQuery({ + queryKey: ['soulseek', 'browse', username, 'level', parent, limit], + queryFn: () => { + const params = new URLSearchParams({ limit: String(limit) }); + if (parent) params.set('parent', parent); + return get(`/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/tree?${params}`); + }, + enabled: enabled && !!username, + staleTime: 5 * 60_000, + }); +} + +type SearchParams = { username: string | null; q: string; enabled?: boolean }; + +/** The filtered tree: matches with their ancestors, in one request, ready to render pre-expanded. */ +export function useSoulseekBrowseSearch({ username, q, enabled = true }: SearchParams) { + const { get } = useClient(); + + return useQuery({ + queryKey: ['soulseek', 'browse', username, 'search', q], + queryFn: () => + get( + `/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/tree/search?q=${encodeURIComponent(q)}`, + ), + enabled: enabled && !!username && !!q, + // The previous result stays on screen while the next one loads, so the tree doesn't blink per keystroke. + placeholderData: (prev) => prev, + staleTime: 60_000, + }); +} + +/** A folder (its whole subtree) or one file inside it, named the way the cache stores them. */ +export type SoulseekDownloadTarget = { path: string; file?: string }; + +/** + * Queue a download from the cache. The sidecar expands a folder into its files and rejoins each one to + * its peer path, so this sends a path rather than a list — the browser never walks the subtree itself. + */ +export function useSoulseekEnqueue(username: string) { + const { post } = useClient(); + + return useMutation({ + mutationFn: (target: SoulseekDownloadTarget) => + post<{ queued: number }>(`/soulseek/_officer/browse/${encodeURIComponent(username)}/download`, target), + onSuccess: ({ queued }, target) => + toast.success(queued === 1 ? `Queued ${target.file ?? basename(target.path)}` : `Queued ${queued} files`), + onError: (err) => toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`), + }); +} + +const basename = (path: string) => path.slice(path.lastIndexOf('\\') + 1); + +/** A single folder's files, fetched only when it's expanded. Cached indefinitely — the rows are immutable. */ +export function useSoulseekBrowseFiles(username: string | null, dirId: number | null) { + const { get } = useClient(); + + return useQuery({ + queryKey: ['soulseek', 'browse', username, 'dir', dirId], + queryFn: () => + get(`/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`), + enabled: !!username && dirId !== null, + staleTime: Infinity, + }); +} diff --git a/web/useSoulseekFavorites.ts b/web/useSoulseekFavorites.ts new file mode 100644 index 0000000..2594c15 --- /dev/null +++ b/web/useSoulseekFavorites.ts @@ -0,0 +1,47 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; + +const KEY = ['soulseek', 'favorites'] as const; +const EMPTY: string[] = []; + +/** + * The owner's favourited Soulseek peers, backed by the officer-slskd sidecar's own `/_officer/favorites` + * route (slskd has no favourites API — Officer owns that data). One shared react-query cache, so every + * star in the workspace reflects the same state; toggling is optimistic and rolls back on failure. + */ +export function useSoulseekFavorites() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + + const { data } = useQuery({ + queryKey: KEY, + queryFn: () => get('/soulseek/_officer/favorites'), + staleTime: 60_000, + }); + + const mutation = useMutation({ + mutationFn: ({ on, username }: { on: boolean; username: string }) => + on + ? post('/soulseek/_officer/favorites', { username }) + : del(`/soulseek/_officer/favorites?username=${encodeURIComponent(username)}`), + onMutate: async ({ on, username }) => { + await qc.cancelQueries({ queryKey: KEY }); + const prev = qc.getQueryData(KEY) ?? EMPTY; + const next = on ? [...prev, username].sort((a, b) => a.localeCompare(b)) : prev.filter((u) => u !== username); + qc.setQueryData(KEY, next); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(KEY, ctx.prev); + }, + }); + + const favorites = data ?? EMPTY; + const isFavorite = (username: string) => favorites.includes(username); + const toggle = (username: string) => { + if (!username) return; + mutation.mutate({ on: !isFavorite(username), username }); + }; + + return { favorites, isFavorite, toggle }; +} diff --git a/web/useSoulseekUser.ts b/web/useSoulseekUser.ts new file mode 100644 index 0000000..10e7396 --- /dev/null +++ b/web/useSoulseekUser.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import type { SlskdUserInfo, SlskdUserStatus } from './shared'; + +/** A peer's presence and profile, as far as either could be reached. */ +export type SoulseekUser = { + username: string; + status: SlskdUserStatus | null; + info: SlskdUserInfo | null; +}; + +/** + * One peer's presence and profile, cached per username. Selecting a peer you've already looked at paints + * from cache instead of blanking the panel back to its empty state and re-asking the network for an + * answer it just had — which is what local state did, once per click. + */ +export function useSoulseekUser(username: string) { + const { get } = useClient(); + + return useQuery({ + queryKey: ['soulseek', 'user', username], + queryFn: async (): Promise => { + const u = encodeURIComponent(username); + const [status, info] = await Promise.allSettled([ + get(`/soulseek/api/v0/users/${u}/status`), + get(`/soulseek/api/v0/users/${u}/info`), + ]); + // Half an answer is still worth showing — a reachable peer with a private profile answers only one + // of the two. Both failing is what actually means "couldn't reach them". + if (status.status === 'rejected' && info.status === 'rejected') { + throw new Error(`Couldn't reach ${username} — they may be offline.`); + } + return { + username, + status: status.status === 'fulfilled' ? status.value : null, + info: info.status === 'fulfilled' ? info.value : null, + }; + }, + enabled: !!username, + // Presence drifts, but not fast enough to be worth a round trip every time the panel remounts. + staleTime: 60_000, + // An unreachable peer IS the answer, not a fault to retry: retrying only delays saying so. + retry: false, + }); +}