From b196c9cb55308e8945b0170981c2d9da29601af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 02:55:35 +0000 Subject: [PATCH] =?UTF-8?q?soulseek,=20extracted=20into=20plugins/soulseek?= =?UTF-8?q?=20=E2=80=94=20the=20platform=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 --- public/slskd.png | Bin 3570 -> 0 bytes scripts/rebuild-soulseek-tree.ts | 52 -- scripts/theme-baseline.json | 8 +- src/apps/officer-web/App.tsx | 2 - .../Dashboard/Soulseek/SoulseekScreen.tsx | 29 - .../Dashboard/Soulseek/defaultLayout.ts | 11 - .../Screens/Dashboard/Soulseek/index.tsx | 1 - .../officer-web/Screens/Dashboard/index.tsx | 1 - src/apps/officer-web/state/usePageTitle.ts | 1 - src/databases/officer_db/src/index.ts | 1 - src/databases/officer_db/src/schema.ts | 1 - .../officer_db/src/soulseek/index.ts | 26 - .../officer_db/src/soulseek/queries.ts | 398 ------------ .../officer_db/src/soulseek/schema.ts | 114 ---- src/servers/api/slskd/router.ts | 18 - src/servers/app-store/catalogue.test.ts | 9 +- src/servers/app-store/catalogue.ts | 21 - src/servers/app-store/installer.test.ts | 10 +- src/servers/app-store/members.test.ts | 4 +- src/servers/hono.ts | 2 - src/servers/permissions/registry.ts | 9 - src/servers/plugins/marketplace.ts | 21 + src/servers/sidecar/slskd/browse.ts | 176 ----- src/servers/sidecar/slskd/config.ts | 114 ---- src/servers/sidecar/slskd/download.ts | 56 -- src/servers/sidecar/slskd/index.ts | 190 ------ src/servers/sidecar/slskd/officer.ts | 195 ------ src/servers/sidecar/slskd/upstream.ts | 75 --- .../src/AppRegistry/AppRegistry.tsx | 2 - .../src/apps/CommandHQ/CommandHqDetail.tsx | 4 +- .../src/apps/CommandHQ/CommandHqGrid.tsx | 2 +- .../officerdev/src/apps/Soulseek/Cards.tsx | 127 ---- .../src/apps/Soulseek/SearchResults.tsx | 614 ------------------ .../src/apps/Soulseek/SearchView.tsx | 276 -------- .../src/apps/Soulseek/SharesBrowser.tsx | 435 ------------- .../src/apps/Soulseek/SoulseekChat.tsx | 267 -------- .../src/apps/Soulseek/SoulseekConnection.tsx | 175 ----- .../src/apps/Soulseek/SoulseekDashboard.tsx | 164 ----- .../src/apps/Soulseek/SoulseekNav.tsx | 77 --- .../src/apps/Soulseek/SoulseekRooms.tsx | 284 -------- .../src/apps/Soulseek/SoulseekSystem.tsx | 145 ----- .../src/apps/Soulseek/SoulseekTransfers.tsx | 494 -------------- .../src/apps/Soulseek/SoulseekUploads.tsx | 227 ------- .../src/apps/Soulseek/SoulseekUsers.tsx | 234 ------- .../src/apps/Soulseek/SoulseekView.tsx | 66 -- .../officerdev/src/apps/Soulseek/UserMenu.tsx | 59 -- .../officerdev/src/apps/Soulseek/index.ts | 11 - .../officerdev/src/apps/Soulseek/shared.ts | 391 ----------- .../src/apps/Soulseek/useSoulseekBrowse.ts | 148 ----- .../src/apps/Soulseek/useSoulseekFavorites.ts | 47 -- .../src/apps/Soulseek/useSoulseekUser.ts | 45 -- src/workspaces/officerdev/src/index.ts | 9 - 52 files changed, 41 insertions(+), 5807 deletions(-) delete mode 100644 public/slskd.png delete mode 100644 scripts/rebuild-soulseek-tree.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx delete mode 100644 src/databases/officer_db/src/soulseek/index.ts delete mode 100644 src/databases/officer_db/src/soulseek/queries.ts delete mode 100644 src/databases/officer_db/src/soulseek/schema.ts delete mode 100644 src/servers/api/slskd/router.ts delete mode 100644 src/servers/sidecar/slskd/browse.ts delete mode 100644 src/servers/sidecar/slskd/config.ts delete mode 100644 src/servers/sidecar/slskd/download.ts delete mode 100644 src/servers/sidecar/slskd/index.ts delete mode 100644 src/servers/sidecar/slskd/officer.ts delete mode 100644 src/servers/sidecar/slskd/upstream.ts delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/index.ts delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/shared.ts delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts diff --git a/public/slskd.png b/public/slskd.png deleted file mode 100644 index 980e0078c7af13cba3537bbc6fad6d2f75ff4a7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/scripts/rebuild-soulseek-tree.ts b/scripts/rebuild-soulseek-tree.ts deleted file mode 100644 index db3e23b5..00000000 --- a/scripts/rebuild-soulseek-tree.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Re-derive the stored tree shape for every cached share snapshot, from the folder rows already in -// Postgres — no peer contact, so it works with everyone offline and is deterministic. -// -// Written for the backfill when the tree columns were added, but kept because it's the answer to any -// future change in tree shape: it runs the SAME buildTree + finishSoulseekBrowse the sidecar's ingest -// runs, so there's no second implementation to drift. -// -// bun scripts/rebuild-soulseek-tree.ts - -import type { BrowsedFile } from 'officerdb'; -import { eq, asc } from 'drizzle-orm'; -import { db, finishSoulseekBrowse } from 'officerdb'; -// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator — -// import them from the feature directly. -import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema'; -import { buildTree } from '../src/servers/sidecar/slskd/browse'; - -const snapshots = await db - .select({ - id: soulseekBrowseSnapshots.id, - userId: soulseekBrowseSnapshots.userId, - username: soulseekBrowseSnapshots.username, - status: soulseekBrowseSnapshots.status, - }) - .from(soulseekBrowseSnapshots) - .orderBy(asc(soulseekBrowseSnapshots.id)); - -console.log(`${snapshots.length} snapshot(s) to rebuild`); - -for (const snap of snapshots) { - const started = Date.now(); - const rows = await db - .select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files }) - .from(soulseekBrowseDirs) - .where(eq(soulseekBrowseDirs.snapshotId, snap.id)); - - if (!rows.length) { - console.log(` #${snap.id} ${snap.username}: no folder rows, skipped`); - continue; - } - - const tree = buildTree(rows.map((r) => ({ name: r.name, files: r.files as BrowsedFile[] }))); - await finishSoulseekBrowse(snap.id, tree); - const roots = tree.filter((t) => t.parentPath === null); - console.log( - ` #${snap.id} ${snap.username}: ${rows.length} -> ${tree.length} rows ` + - `(+${tree.length - rows.length} synthesized), ${roots.length} root(s), ` + - `${roots.reduce((n, r) => n + r.subtreeFileCount, 0)} files, ${Date.now() - started}ms`, - ); -} - -process.exit(0); diff --git a/scripts/theme-baseline.json b/scripts/theme-baseline.json index 62d1f9f4..1ff1946c 100644 --- a/scripts/theme-baseline.json +++ b/scripts/theme-baseline.json @@ -1,5 +1,5 @@ { - "src/apps/officer-web/Screens": 144, + "src/apps/officer-web/Screens": 122, "src/workspaces/components": 47, "src/workspaces/officerdev/src/apps/AppStore": 6, "src/workspaces/officerdev/src/apps/Chat": 12, @@ -11,13 +11,9 @@ "src/workspaces/officerdev/src/apps/FileViewer": 38, "src/workspaces/officerdev/src/apps/Invoices": 95, "src/workspaces/officerdev/src/apps/Jellyfin": 48, - "src/workspaces/officerdev/src/apps/Photos": 47, "src/workspaces/officerdev/src/apps/Preview": 4, "src/workspaces/officerdev/src/apps/QrTransfer": 5, - "src/workspaces/officerdev/src/apps/Soulseek": 313, "src/workspaces/officerdev/src/apps/SystemMonitor": 1, "src/workspaces/officerdev/src/apps/Terminal": 10, - "src/workspaces/officerdev/src/apps/Transmission": 43, - "src/workspaces/officerdev/src/apps/Wallet": 68, - "src/workspaces/officerdev/src/components": 22 + "src/workspaces/officerdev/src/apps/Wallet": 68 } diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index bc822f41..611dd611 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -80,8 +80,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx deleted file mode 100644 index dde79a1f..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Navigate, useParams } from 'react-router'; -import type { LayoutNode } from 'officerdev'; -import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /soulseek uses the Workspace/Panel system (like /command-hq): a section nav (soulseek-nav) on the left and -// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection — -// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy. - -export const SoulseekScreen = () => { - const { section } = useParams(); - // v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted - // layout doesn't resurrect as two mismatched panels. - const workspace = useDashboardState('screens/soulseek-v2', defaultLayout); - - // After the hooks — it returns early. - if (!isSoulseekSection(section)) return ; - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts deleted file mode 100644 index e5f81710..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'soulseek-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'soulseek-nav', appType: 'soulseek-nav' }, size: 22 }, - { node: { type: 'panel', id: 'soulseek-view', appType: 'soulseek-view' }, size: 78 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx deleted file mode 100644 index b6db6bc2..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './SoulseekScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 083549d1..aaa17b71 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -13,7 +13,6 @@ export * from './Tasks'; export * from './Files'; export * from './Calendar'; export * from './Contacts'; -export * from './Soulseek'; export * from './CommandHQ'; export * from './Jellyfin'; export * from './Gitea'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 8e7afd56..acebe7fc 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -24,7 +24,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' }, { match: (p) => p.startsWith('/plugins'), title: 'Marketplace' }, { match: (p) => p.startsWith('/jellyfin'), title: 'Video' }, - { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' }, { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, { match: (p) => p.startsWith('/invoices'), title: 'Invoices' }, diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 8a2e7c5a..ecc908f1 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -49,7 +49,6 @@ export * from './dav'; export * from './invoiceshelf'; export * from './jellyfin'; export * from './notify'; -export * from './soulseek'; export * from './wallet'; // `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index b6318807..2f26f6b4 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -50,7 +50,6 @@ export * from './service-connections/schema'; // service_connections // export * from './dav/schema'; // dav_app_passwords officer-caldav // export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin // export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf -// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs // export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache // ── Plugin tables are NOT here ─────────────────────────────────────────────────────────────────── diff --git a/src/databases/officer_db/src/soulseek/index.ts b/src/databases/officer_db/src/soulseek/index.ts deleted file mode 100644 index 02ed187b..00000000 --- a/src/databases/officer_db/src/soulseek/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries'; - -export { - getSoulseekBrowseSnapshots, - getSoulseekBrowseSnapshot, - startSoulseekBrowse, - finishSoulseekBrowse, - failSoulseekBrowse, - resetStaleSoulseekBrowses, - getSoulseekBrowseLevel, - searchSoulseekBrowseTree, - getSoulseekBrowseDirFiles, - getSoulseekBrowseDownload, - deleteSoulseekBrowse, -} from './queries'; - -export type { - BrowseDownloadFile, - BrowsedFile, - BrowseDirInput, - BrowseDirRow, - BrowseTreeNode, - BrowseLevel, - BrowseTreeSearch, - SoulseekBrowseSnapshot, -} from './queries'; diff --git a/src/databases/officer_db/src/soulseek/queries.ts b/src/databases/officer_db/src/soulseek/queries.ts deleted file mode 100644 index a88090aa..00000000 --- a/src/databases/officer_db/src/soulseek/queries.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm'; -import { db } from '../db'; -import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from './schema'; - -/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */ -export async function getSoulseekFavorites(userId: number): Promise { - const rows = await db - .select({ username: soulseekFavorites.username }) - .from(soulseekFavorites) - .where(eq(soulseekFavorites.userId, userId)) - .orderBy(asc(soulseekFavorites.username)); - return rows.map((r) => r.username); -} - -/** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */ -export async function addSoulseekFavorite(userId: number, username: string): Promise { - await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing(); -} - -/** Remove a favourite (no-op if it wasn't set). */ -export async function removeSoulseekFavorite(userId: number, username: string): Promise { - await db - .delete(soulseekFavorites) - .where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username))); -} - -// ── Cached share trees ── - -/** A file inside a cached folder, slimmed at ingest (see the schema note on dropped attributes). */ -export type BrowsedFile = { name: string; size: number; extension: string }; -/** One folder as slskd gives it: a full backslash-delimited path, plus its files. */ -export type BrowseDirInput = { name: string; files: BrowsedFile[] }; - -/** - * One folder as it's stored — a tree node. slskd's flat paths are linked up into this shape by the - * sidecar's `buildTree` before they get here, which is why every count is already on the row. - */ -export type BrowseDirRow = { - name: string; - parentPath: string | null; - depth: number; - label: string; - childCount: number; - fileCount: number; - totalSize: number; - subtreeFileCount: number; - subtreeSize: number; - files: BrowsedFile[]; -}; - -export type SoulseekBrowseSnapshot = { - username: string; - status: string; - error: string | null; - directoryCount: number; - fileCount: number; - totalSize: number; - startedAt: Date; - completedAt: Date | null; -}; - -const snapshotCols = { - username: soulseekBrowseSnapshots.username, - status: soulseekBrowseSnapshots.status, - error: soulseekBrowseSnapshots.error, - directoryCount: soulseekBrowseSnapshots.directoryCount, - fileCount: soulseekBrowseSnapshots.fileCount, - totalSize: soulseekBrowseSnapshots.totalSize, - startedAt: soulseekBrowseSnapshots.startedAt, - completedAt: soulseekBrowseSnapshots.completedAt, -}; - -/** Every cached share tree for a user, so the UI can badge each favourite in one request. */ -export async function getSoulseekBrowseSnapshots(userId: number): Promise { - return db - .select(snapshotCols) - .from(soulseekBrowseSnapshots) - .where(eq(soulseekBrowseSnapshots.userId, userId)) - .orderBy(asc(soulseekBrowseSnapshots.username)); -} - -/** One peer's snapshot, or null if never fetched. */ -export async function getSoulseekBrowseSnapshot( - userId: number, - username: string, -): Promise { - const [row] = await db - .select(snapshotCols) - .from(soulseekBrowseSnapshots) - .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username))) - .limit(1); - return row ?? null; -} - -/** - * Mark a peer's tree as being fetched and return the snapshot id to ingest into. Deliberately leaves the - * existing folder rows and counts alone: they stay readable while the refresh runs, and survive it failing. - */ -export async function startSoulseekBrowse(userId: number, username: string): Promise { - const [row] = await db - .insert(soulseekBrowseSnapshots) - .values({ userId, username, status: 'pending' }) - .onConflictDoUpdate({ - target: [soulseekBrowseSnapshots.userId, soulseekBrowseSnapshots.username], - set: { status: 'pending', error: null, startedAt: new Date(), completedAt: null }, - }) - .returning({ id: soulseekBrowseSnapshots.id }); - if (!row) throw new Error('failed to create browse snapshot'); - return row.id; -} - -// Each row carries a whole folder's file list, so keep batches small enough that no single INSERT gets -// absurd. 18k folders is ~36 round trips at this size. -const INGEST_BATCH = 500; - -/** Swap in a freshly built tree: replace the folder rows and mark the snapshot ready, atomically. */ -export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise { - const rows = tree.map((r) => ({ ...r, snapshotId })); - await db.transaction(async (tx) => { - await tx.delete(soulseekBrowseDirs).where(eq(soulseekBrowseDirs.snapshotId, snapshotId)); - for (let i = 0; i < rows.length; i += INGEST_BATCH) { - await tx.insert(soulseekBrowseDirs).values(rows.slice(i, i + INGEST_BATCH)); - } - // Summed over the roots, not over every row: the subtree rollups already include descendants, so - // adding them all up would count each file once per level above it. - const roots = rows.filter((r) => r.parentPath === null); - await tx - .update(soulseekBrowseSnapshots) - .set({ - status: 'ready', - error: null, - completedAt: new Date(), - directoryCount: rows.length, - fileCount: roots.reduce((n, r) => n + r.subtreeFileCount, 0), - totalSize: roots.reduce((n, r) => n + r.subtreeSize, 0), - }) - .where(eq(soulseekBrowseSnapshots.id, snapshotId)); - }); -} - -/** Record a failed fetch. Counts and folder rows are left as they were — a stale cache beats none. */ -export async function failSoulseekBrowse(snapshotId: number, error: string): Promise { - await db - .update(soulseekBrowseSnapshots) - .set({ status: 'failed', error: error.slice(0, 1000), completedAt: new Date() }) - .where(eq(soulseekBrowseSnapshots.id, snapshotId)); -} - -/** - * Fail every snapshot still marked pending. Called on sidecar boot: an in-flight fetch dies with the - * process, so without this a crash mid-browse leaves a row spinning in the UI forever. - */ -export async function resetStaleSoulseekBrowses(): Promise { - const rows = await db - .update(soulseekBrowseSnapshots) - .set({ status: 'failed', error: 'interrupted by a sidecar restart', completedAt: new Date() }) - .where(eq(soulseekBrowseSnapshots.status, 'pending')) - .returning({ id: soulseekBrowseSnapshots.id }); - return rows.length; -} - -/** A tree row as the UI renders it. `files` stays out — those load per folder, on expand. */ -export type BrowseTreeNode = { - id: number; - name: string; - parentPath: string | null; - label: string; - depth: number; - childCount: number; - fileCount: number; - totalSize: number; - subtreeFileCount: number; - subtreeSize: number; -}; - -const treeCols = { - id: soulseekBrowseDirs.id, - name: soulseekBrowseDirs.name, - parentPath: soulseekBrowseDirs.parentPath, - label: soulseekBrowseDirs.label, - depth: soulseekBrowseDirs.depth, - childCount: soulseekBrowseDirs.childCount, - fileCount: soulseekBrowseDirs.fileCount, - totalSize: soulseekBrowseDirs.totalSize, - subtreeFileCount: soulseekBrowseDirs.subtreeFileCount, - subtreeSize: soulseekBrowseDirs.subtreeSize, -}; - -/** The snapshot a request is scoped to, or null if this peer was never fetched. */ -async function snapshotIdOf(userId: number, username: string): Promise { - const [snap] = await db - .select({ id: soulseekBrowseSnapshots.id }) - .from(soulseekBrowseSnapshots) - .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username))) - .limit(1); - return snap?.id ?? null; -} - -export type BrowseLevel = { nodes: BrowseTreeNode[]; total: number }; -type BrowseLevelParams = { userId: number; username: string; parent?: string; limit: number; offset: number }; - -/** - * One level of the tree: the children of `parent`, or the roots when it's absent. Paged, because fan-out - * is not gentle — the widest folder measured on a real peer has 1,181 children. - */ -export async function getSoulseekBrowseLevel(params: BrowseLevelParams): Promise { - const { userId, username, parent, limit, offset } = params; - const snapshotId = await snapshotIdOf(userId, username); - if (snapshotId === null) return { nodes: [], total: 0 }; - - const where = and( - eq(soulseekBrowseDirs.snapshotId, snapshotId), - parent ? eq(soulseekBrowseDirs.parentPath, parent) : isNull(soulseekBrowseDirs.parentPath), - ); - - const [nodes, [count]] = await Promise.all([ - db - .select(treeCols) - .from(soulseekBrowseDirs) - .where(where) - .orderBy(asc(soulseekBrowseDirs.name)) - .limit(limit) - .offset(offset), - db - .select({ total: sql`count(*)::int` }) - .from(soulseekBrowseDirs) - .where(where), - ]); - return { nodes, total: count?.total ?? 0 }; -} - -// These names come off strangers' filesystems, so they're littered with typographic punctuation that no -// keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'. -// Typing "Hell's Ditch" or "1984-1985" found nothing at all, so both sides get folded to ASCII first. -// Same character set on both sides — extend one and you must extend the other. -const FOLD_FROM = '‘’ʼ'“”‐‑‒–—―− '; -const FOLD_TO = "''''\"\"------- "; -// Not in the translate() pair because it's one character standing in for three. -const ELLIPSIS = '…'; - -const foldPunctuation = (value: string): string => { - let out = ''; - for (const ch of value) { - const at = FOLD_FROM.indexOf(ch); - out += at === -1 ? ch : FOLD_TO[at]; - } - return out.split(ELLIPSIS).join('...'); -}; - -// Postgres reads '%', '_' and '\' as LIKE metacharacters, so unescaped input meant a typed '_' matched -// every folder in the share while a pasted path — and every name here IS a backslash-delimited path — -// silently matched none. Backslash stays the escape character, which is why it's escaped first. -const likePattern = (term: string): string => `%${foldPunctuation(term).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`; - -/** The column, folded the same way `likePattern` folds the term. */ -const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`; - -export type BrowseTreeSearch = { - /** Matches AND every ancestor of a match, so the UI has a whole tree to render, not orphans. */ - nodes: BrowseTreeNode[]; - /** `name`s of the rows that actually matched — the rest of `nodes` are there to hold them up. */ - matched: string[]; - /** Matches in the snapshot, which exceeds `matched.length` when the cap kicked in. */ - total: number; - truncated: boolean; -}; -type BrowseTreeSearchParams = { userId: number; username: string; q: string; limit: number }; - -const SEP = '\\'; - -/** Every path above `name`, longest first: 'a\b\c' → ['a\b', 'a']. */ -function ancestorsOf(name: string): string[] { - const out: string[] = []; - for (let cut = name.lastIndexOf(SEP); cut > 0; cut = name.lastIndexOf(SEP, cut - 1)) { - out.push(name.slice(0, cut)); - } - return out; -} - -/** - * Filtered view of the tree. Filtering never flattens it: matches come back with their full ancestor - * chains, which is what lets the UI open straight down to a hit and keep the shape visible around it. - * - * Ancestors are resolved by string prefix rather than a recursive CTE — the parent's path IS a prefix of - * the child's, so one indexed `name in (…)` lookup does what a walk would take `depth` round trips to do. - */ -export async function searchSoulseekBrowseTree(params: BrowseTreeSearchParams): Promise { - const { userId, username, q, limit } = params; - const snapshotId = await snapshotIdOf(userId, username); - const term = q.trim(); - if (snapshotId === null || !term) return { nodes: [], matched: [], total: 0, truncated: false }; - - // Words are AND-ed, each matched anywhere in the path, so 'pogues hell' finds - // '…\The Pogues\… - Hell's Ditch …' — a folder you can name from two ends but never type in full. - // ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's - // ~18k rows — which is why folding the column, once per word, costs nothing on top. - const words = term.split(/\s+/).filter(Boolean); - const where = and( - eq(soulseekBrowseDirs.snapshotId, snapshotId), - ...words.map((word) => sql`${foldedName} ilike ${likePattern(word)}`), - ); - - const [matches, [count]] = await Promise.all([ - db.select(treeCols).from(soulseekBrowseDirs).where(where).orderBy(asc(soulseekBrowseDirs.name)).limit(limit), - db - .select({ total: sql`count(*)::int` }) - .from(soulseekBrowseDirs) - .where(where), - ]); - - const have = new Set(matches.map((m) => m.name)); - const wanted = new Set(); - for (const m of matches) for (const a of ancestorsOf(m.name)) if (!have.has(a)) wanted.add(a); - - const ancestors = wanted.size - ? await db - .select(treeCols) - .from(soulseekBrowseDirs) - .where(and(eq(soulseekBrowseDirs.snapshotId, snapshotId), inArray(soulseekBrowseDirs.name, [...wanted]))) - : []; - - const nodes = [...matches, ...ancestors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - const total = count?.total ?? 0; - return { nodes, matched: matches.map((m) => m.name), total, truncated: total > matches.length }; -} - -/** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */ -type DirFilesParams = { userId: number; username: string; dirId: number }; - -// Joined and scoped by BOTH owner and peer: a dir id alone would happily return another peer's folder -// (ids are global), so the username in the request URL has to be part of the predicate, not decoration. -export async function getSoulseekBrowseDirFiles({ - userId, - username, - dirId, -}: DirFilesParams): Promise { - const [row] = await db - .select({ files: soulseekBrowseDirs.files }) - .from(soulseekBrowseDirs) - .innerJoin(soulseekBrowseSnapshots, eq(soulseekBrowseDirs.snapshotId, soulseekBrowseSnapshots.id)) - .where( - and( - eq(soulseekBrowseDirs.id, dirId), - eq(soulseekBrowseSnapshots.userId, userId), - eq(soulseekBrowseSnapshots.username, username), - ), - ) - .limit(1); - return row ? (row.files as BrowsedFile[]) : null; -} - -/** A file as slskd wants it enqueued: the peer's own full path, plus the size it advertised. */ -export type BrowseDownloadFile = { filename: string; size: number }; -type BrowseDownloadParams = { userId: number; username: string; path: string; file?: string }; - -/** - * Resolve a download request to the exact files to enqueue — one folder's subtree, or a single file. - * - * Sizes and paths come from the cache rather than the client: slskd matches a queued download on - * filename AND size, so a wrong number is a transfer that never starts. The stored file name is a - * basename (browse, unlike search, doesn't repeat the folder), so the peer's real path is rejoined here. - */ -export async function getSoulseekBrowseDownload({ - userId, - username, - path, - file, -}: BrowseDownloadParams): Promise { - const snapshotId = await snapshotIdOf(userId, username); - if (snapshotId === null) return []; - - const rows = await db - .select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files }) - .from(soulseekBrowseDirs) - .where( - and( - eq(soulseekBrowseDirs.snapshotId, snapshotId), - // starts_with, not LIKE: these paths are full of backslashes, and one would be a LIKE escape. - file - ? eq(soulseekBrowseDirs.name, path) - : sql`(${soulseekBrowseDirs.name} = ${path} or starts_with(${soulseekBrowseDirs.name}, ${path + SEP}))`, - ), - ) - .orderBy(asc(soulseekBrowseDirs.name)); - - return rows.flatMap((row) => - (row.files as BrowsedFile[]) - .filter((f) => (file ? f.name === file : true)) - .map((f) => ({ filename: `${row.name}${SEP}${f.name}`, size: f.size })), - ); -} - -/** Drop a peer's cache entirely (snapshot + folders, via the FK cascade). */ -export async function deleteSoulseekBrowse(userId: number, username: string): Promise { - await db - .delete(soulseekBrowseSnapshots) - .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username))); -} diff --git a/src/databases/officer_db/src/soulseek/schema.ts b/src/databases/officer_db/src/soulseek/schema.ts deleted file mode 100644 index fd54bdf5..00000000 --- a/src/databases/officer_db/src/soulseek/schema.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - pgTable, - serial, - integer, - bigint, - text, - timestamp, - index, - jsonb, - foreignKey, - uniqueIndex, -} from 'drizzle-orm/pg-core'; -import { users } from '../auth/schema'; - -// Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API -// (verified against 0.26.0's UsersController: only endpoint/browse/directory/info/status), so the peers -// the owner marks live here instead of in a forked daemon. -// -// Every table here is `soulseek_`-prefixed, and this file deliberately holds nothing else: when sidecars -// start owning their own schema, it moves wholesale into src/servers/sidecar/slskd/ with no untangling. -// Only the officer-slskd sidecar reads or writes these tables. - -export const soulseekFavorites = pgTable( - 'soulseek_favorites', - { - id: serial('id').primaryKey(), - userId: integer('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - username: text('username').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - }, - // Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix. - (t) => [uniqueIndex('uq_soulseek_favorites_user_username').on(t.userId, t.username)], -); - -// ── Cached share trees ── -// -// A peer's share tree comes from slskd as ONE blocking response with every file of every folder inlined: -// measured at 59 MB / 18k folders / 284k files for a single real peer. Sending that to a browser is -// hopeless, and the browse is lost the moment the tab navigates away. So the sidecar fetches it in the -// background and caches it here, and the UI reads paginated slices instead. -// -// One snapshot row per (user, peer) — refreshes replace it rather than accumulating history. - -export const soulseekBrowseSnapshots = pgTable( - 'soulseek_browse_snapshots', - { - id: serial('id').primaryKey(), - userId: integer('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - username: text('username').notNull(), - // 'pending' while the sidecar is fetching, then 'ready' or 'failed'. A failed refresh keeps the - // previous rows, so a peer going offline never destroys a good cache. - status: text('status').notNull().default('pending'), - error: text('error'), - directoryCount: integer('directory_count').notNull().default(0), - fileCount: integer('file_count').notNull().default(0), - // Shares run to terabytes, well past int4. - totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0), - startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), - completedAt: timestamp('completed_at', { withTimezone: true }), - }, - (t) => [uniqueIndex('uq_soulseek_browse_snapshots_user_username').on(t.userId, t.username)], -); - -// One row per FOLDER, with that folder's files inlined as jsonb — not a row per file, which would be -// 284k rows per peer. Folders are what you page and filter through; files are only ever read for the -// folder you opened, so they ride along in the row. -// -// slskd hands back a flat list of backslash-delimited paths, so the tree is derived once at ingest and -// stored here rather than recomputed per request: `parentPath` links a row to its parent and the subtree -// rollups are precomputed, which is what lets the UI open one level at a time. Measured on real peers, -// the tree is already complete in slskd's response — exactly one intermediate folder was missing across -// 30k rows — but ingest synthesizes any that are absent so a level can never dead-end. -export const soulseekBrowseDirs = pgTable( - 'soulseek_browse_dirs', - { - id: serial('id').primaryKey(), - snapshotId: integer('snapshot_id').notNull(), - name: text('name').notNull(), - // null for a root. Its parent's `name`, i.e. `name` minus the last backslash segment. - parentPath: text('parent_path'), - // Segment count, 1-based, so the UI can indent without walking back up the chain. - depth: integer('depth').notNull().default(1), - // Last path segment — what a tree row actually displays. - label: text('label').notNull().default(''), - childCount: integer('child_count').notNull().default(0), - fileCount: integer('file_count').notNull().default(0), - totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0), - // This folder AND everything under it. A parent's own file count is usually cover art; the number - // worth showing on a collapsed row is the whole subtree's. - subtreeFileCount: integer('subtree_file_count').notNull().default(0), - subtreeSize: bigint('subtree_size', { mode: 'number' }).notNull().default(0), - // BrowsedFile[] — slimmed at ingest to {name,size,extension}: slskd's browse response carries - // per-file `attributes` (bitrate/duration) that real peers leave empty, and dropping them plus the - // wire noise took the sample from 59 MB to 19 MB. - files: jsonb('files').notNull().default([]), - }, - (t) => [ - // Filtered search and the name lookups behind ancestor expansion. - index('idx_soulseek_browse_dirs_snapshot_name').on(t.snapshotId, t.name), - // The tree's hot path: one level, ordered by name. Covers "the roots" too, via a null parent_path. - index('idx_soulseek_browse_dirs_snapshot_parent').on(t.snapshotId, t.parentPath, t.name), - // Named explicitly: drizzle's derived name for this pair would be 64 chars, one over Postgres's - // 63-char identifier cap, so it would be silently truncated and never match the schema again. - foreignKey({ - name: 'soulseek_browse_dirs_snapshot_id_fk', - columns: [t.snapshotId], - foreignColumns: [soulseekBrowseSnapshots.id], - }).onDelete('cascade'), - ], -); diff --git a/src/servers/api/slskd/router.ts b/src/servers/api/slskd/router.ts deleted file mode 100644 index f3c63d96..00000000 --- a/src/servers/api/slskd/router.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; - -// /api/slskd/* — auth, then forward to officer-slskd. No routes of its own and no slskd knowledge: -// this file must never grow app logic. -// -// Covers both slskd's own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned -// routes (/_officer/*, features slskd has no concept of). Both are opaque from here; the sidecar injects -// the slskd API key. The full contract is documented at the top of src/servers/sidecar/slskd/index.ts. - -const proxy = createSidecarProxy({ - name: 'slskd', - prefix: '/api/slskd', -}); - -export const slskdRouter = proxy.router; - -/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getSlskdServerUrl = proxy.getHttpUrl; diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 4a407856..2afaea31 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -107,10 +107,11 @@ describe('member provisioning is declared for every entry', () => { }); it('never promises accounts for a single-tenant daemon', () => { - // slskd has no user concept; claiming otherwise would make the installer try to create accounts - // against an API that does not exist. (Transmission was the other example until 2026-08-18, when it - // became `plugins/transmission/`.) - for (const id of ['slskd']) expect(byId(id)!.members).toBe('none'); + // A daemon with no user concept; claiming otherwise would make the installer try to create accounts + // against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until + // 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list + // rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing. + for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none'); }); // A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 362a868a..2be1b7e9 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -226,27 +226,6 @@ export const CATALOGUE: CatalogueEntry[] = [ // Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in // `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because // `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there. - { - id: 'slskd', - ui: { - name: 'Soulseek', - image: '/plugins/slskd/icon.png', - color: '#ffffff', - rootRoute: '/soulseek', - routes: ['/soulseek'], - }, - process: 'officer-slskd', - label: 'Soulseek', - summary: 'slskd — search and download from the Soulseek network', - members: 'none', - modes: ['existing', 'provisioned'], - permission: 'soulseek', - composeTemplate: 'slskd', - existingFields: [ - { key: 'url', label: 'slskd URL', type: 'url', required: true }, - { key: 'secret', label: 'API key', type: 'secret', required: true }, - ], - }, { id: 'caldav', ui: { diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts index 1d188011..400c6b64 100644 --- a/src/servers/app-store/installer.test.ts +++ b/src/servers/app-store/installer.test.ts @@ -7,7 +7,13 @@ import { byId } from './catalogue'; // when something goes wrong: resume, blocking, and not doing work twice. const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts' -const slskd = byId('slskd')!; // existing|provisioned, members: 'none' +// A LITERAL, not `byId(...)`. This was `byId('slskd')` until slskd became `plugins/soulseek/`, and +// before that the same assertion had to be re-pointed when photos and transmission left. No catalogue +// entry today is both `provisioned` and `members: 'none'`, so there is nothing left to borrow — and +// borrowing was the mistake anyway: this pins `planSteps`, which is installer logic, and it should not +// break because an unrelated service was extracted. Shaped from jellyfin so the rest of the entry stays +// realistic. +const noMemberConcept = { ...byId('jellyfin')!, id: 'test-single-tenant', members: 'none' as const }; const configOnly = byId('notify')!; // config only /** Records what was actually called, so a test can assert on absence as well as presence. */ @@ -46,7 +52,7 @@ describe('planSteps', () => { it('omits the members step for a service with no user concept', () => { // Otherwise a Transmission install reports a members step that did nothing, which reads as a // silent failure to anyone debugging why a member has no access. - expect(planSteps(slskd, 'provisioned')).not.toContain('members'); + expect(planSteps(noMemberConcept, 'provisioned')).not.toContain('members'); expect(planSteps(jellyfin, 'provisioned')).toContain('members'); }); diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index b17d3a45..009dc25f 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -12,13 +12,13 @@ describe('what adding a member would do', () => { it('predicts the outcome per service without contacting anything', () => { expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned'); expect(plannedOutcome(inviteOnly)).toBe('invited'); - expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable'); + expect(plannedOutcome(byId('vnc')!)).toBe('not-applicable'); }); }); describe('which installed services have work to do', () => { it('skips single-tenant daemons entirely', () => { - const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id); + const ids = servicesNeedingMemberProvision(['jellyfin', 'vnc', 'memos']).map((e) => e.id); expect(ids.sort()).toEqual(['jellyfin', 'memos']); }); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 1dd789ea..47b53bb0 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router'; import { pluginsRouter } from './api/plugins/router'; import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths'; import { agentHandoffRouter } from './api/agent-handoff/router'; -// import { slskdRouter } from './api/slskd/router'; // import { invoiceshelfRouter } from './api/invoiceshelf/router'; // import { jellyfinRouter } from './api/jellyfin/router'; // import { walletRouter } from './api/wallet/router'; @@ -141,7 +140,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it ['/dashboards', dashboardsRouter], ['/file-browser', fileBrowserRouter], - // ['/slskd', slskdRouter], // plugin — switched off 2026-08-13 ['/terminal', terminalRouter], // ['/memos', memosRouter], // plugin — switched off 2026-08-13 // ['/gitea', giteaRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index a78db3f3..4652269d 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -200,15 +200,6 @@ const CORE_REGISTRY: Permission[] = [ // Device registration is the caller's own — a phone subscribing to its own push channel. personal: ['/devices', '/subscriptions'], }, - { - key: 'soulseek', - label: 'Soulseek', - description: 'Search and download from the Soulseek network', - kind: 'app', - // api: ['/slskd'], // plugin — switched off 2026-08-13 - api: [], - routes: ['/soulseek'], - }, { key: 'invoices', label: 'Invoices', diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 26130bc5..6edf66bb 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -149,6 +149,27 @@ const BUNDLED: CatalogueEntry[] = [ // `schema: false` — the connection lives in `service_connections`, a core table. See its manifest. has: { api: true, schema: false, sidecar: true, web: true }, }, + { + appName: 'soulseek', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Soulseek', + summary: 'Search and download from the Soulseek network, via your own slskd daemon', + color: '#22c55e', + icon: 'Music2', + source: 'https://gitea.officer.dev/plugins/soulseek.git', + permissions: [ + { + key: 'soulseek', + label: 'Soulseek', + description: 'Search and download from the Soulseek network', + }, + ], + // `schema: true` unlike transmission: the CONNECTION lives in core `service_connections`, but the + // favourites and the cached browse tree are this plugin's own three tables. + has: { api: true, schema: true, sidecar: true, web: true }, + }, { appName: 'vault', publisher: 'officerdev', diff --git a/src/servers/sidecar/slskd/browse.ts b/src/servers/sidecar/slskd/browse.ts deleted file mode 100644 index b14d63e7..00000000 --- a/src/servers/sidecar/slskd/browse.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb'; -import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb'; -import { authHeaders, getSlskdConfig } from './upstream'; - -// Background share-tree fetches. -// -// slskd's GET /users/{u}/browse is a single blocking call that inlines every file of every folder — -// measured at 59 MB / 18k folders / 284k files for one real peer, and it can take minutes because it -// round-trips to that peer over the Soulseek network. Doing it from the browser means a tab that hangs -// and throws the result away on navigation. -// -// So it runs HERE instead: the route kicks this off and returns immediately, the fetch outlives the -// request (and the tab), and the result lands in Postgres for the UI to page through. The sidecar is a -// long-lived PM2 process, which is what makes "come back later and it's there" work at all. - -// slskd's wire shape. Files carry `attributes` (bitrate/duration) that real peers leave empty, plus -// `code`/`attributeCount` noise; all of it is dropped at ingest. -type SlskdBrowseFile = { filename: string; size?: number; extension?: string }; -type SlskdBrowseDir = { name: string; fileCount?: number; files?: SlskdBrowseFile[] }; -type SlskdBrowsePayload = { directories?: SlskdBrowseDir[] } | SlskdBrowseDir[]; - -// One browse per (user, peer) at a time. A second click while one is running is a no-op rather than a -// duplicate multi-minute fetch of the same tree. -const inFlight = new Map>(); -const keyOf = (userId: number, username: string) => `${userId}:${username}`; - -/** Peers currently being fetched, so a caller can report "already running" without racing the map. */ -export const isBrowseRunning = (userId: number, username: string): boolean => inFlight.has(keyOf(userId, username)); - -// Generous, but not unbounded: a peer that accepts the connection and then stalls shouldn't pin a -// snapshot in 'pending' forever. -const BROWSE_TIMEOUT_MS = 10 * 60 * 1000; - -const extensionOf = (file: SlskdBrowseFile): string => { - const raw = file.extension?.trim(); - if (raw) return raw.replace(/^\./, '').toLowerCase(); - const dot = file.filename.lastIndexOf('.'); - return dot > 0 ? file.filename.slice(dot + 1).toLowerCase() : ''; -}; - -/** - * Strip the response to what the UI actually renders. Locked directories are skipped: they're shares the - * peer won't serve us, so listing them would only offer downloads that always fail. - */ -function slim(payload: SlskdBrowsePayload): BrowseDirInput[] { - const dirs = Array.isArray(payload) ? payload : (payload?.directories ?? []); - return dirs.map((dir) => { - const files: BrowsedFile[] = (dir.files ?? []).map((f) => ({ - // Browse gives a basename only, unlike search results' full remote path. Downloading one means - // rejoining it to the directory name with a backslash. - name: f.filename, - size: f.size ?? 0, - extension: extensionOf(f), - })); - return { name: dir.name, files }; - }); -} - -const SEP = '\\'; - -/** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */ -function splitPath(name: string): { parentPath: string | null; label: string; depth: number } { - const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name; - const cut = trimmed.lastIndexOf(SEP); - const depth = trimmed.split(SEP).filter(Boolean).length || 1; - // `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is - // neither null nor a real row, and the level query would never find it. - return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth }; -} - -/** - * Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a - * time instead of the platform recomputing the shape on every request. - * - * Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders) - * exactly one intermediate folder was missing, so they're nearly always present, but a single gap would - * make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder. - * Rollups: a parent's own file count is usually just cover art, so the number worth showing on a - * collapsed row is the subtree's, and that's summed bottom-up here rather than per request. - */ -export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] { - const byName = new Map(); - - const ensure = (name: string): BrowseDirRow => { - const existing = byName.get(name); - if (existing) return existing; - const { parentPath, label, depth } = splitPath(name); - const row: BrowseDirRow = { - name, - parentPath, - label, - depth, - childCount: 0, - fileCount: 0, - totalSize: 0, - subtreeFileCount: 0, - subtreeSize: 0, - files: [], - }; - // Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow. - byName.set(name, row); - if (parentPath) ensure(parentPath); - return row; - }; - - for (const dir of dirs) { - const row = ensure(dir.name); - // Concatenated rather than assigned: a path repeated in the response would otherwise lose files. - row.files = row.files.length ? row.files.concat(dir.files) : dir.files; - row.fileCount = row.files.length; - row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0); - } - - const rows = [...byName.values()]; - for (const row of rows) { - row.subtreeFileCount = row.fileCount; - row.subtreeSize = row.totalSize; - } - // Deepest first, so every child is final before its parent absorbs it. - for (const row of [...rows].sort((a, b) => b.depth - a.depth)) { - const parent = row.parentPath ? byName.get(row.parentPath) : undefined; - if (!parent) continue; - parent.childCount += 1; - parent.subtreeFileCount += row.subtreeFileCount; - parent.subtreeSize += row.subtreeSize; - } - return rows; -} - -async function run(userId: number, username: string, snapshotId: number): Promise { - const cfg = await getSlskdConfig(userId); - if (!cfg) { - await failSoulseekBrowse(snapshotId, 'slskd upstream not configured'); - return; - } - const started = Date.now(); - try { - const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, { - headers: authHeaders(cfg), - signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS), - }); - if (!res.ok) { - const detail = (await res.text().catch(() => '')).slice(0, 200); - throw new Error(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`); - } - - const dirs = slim((await res.json()) as SlskdBrowsePayload); - const tree = buildTree(dirs); - await finishSoulseekBrowse(snapshotId, tree); - const files = dirs.reduce((n, d) => n + d.files.length, 0); - // Against distinct names, not the array length, so a repeated path doesn't read as a negative. - const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size; - console.log( - `[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${files} files in ${Date.now() - started}ms`, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`); - await failSoulseekBrowse(snapshotId, message); - } -} - -/** - * Start caching a peer's share tree and return once it's *registered*, not once it's done — the caller - * responds 202 and the fetch continues in the background. Resolves false if one was already running. - */ -export async function startBrowse(userId: number, username: string): Promise { - const key = keyOf(userId, username); - if (inFlight.has(key)) return false; - - const snapshotId = await startSoulseekBrowse(userId, username); - // Registered before awaiting anything inside run(), so two rapid clicks can't both get past the check. - const task = run(userId, username, snapshotId).finally(() => inFlight.delete(key)); - inFlight.set(key, task); - return true; -} diff --git a/src/servers/sidecar/slskd/config.ts b/src/servers/sidecar/slskd/config.ts deleted file mode 100644 index ea8640b1..00000000 --- a/src/servers/sidecar/slskd/config.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { SlskdConfig } from './upstream'; -import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb'; -import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } from './upstream'; - -// `/_config` — where the slskd daemon is and what key opens it, driven from the app rather than from a -// shell on the server. -// -// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it -// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send -// a new one, which is the same shape the photos and offscale registries use. -// -// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the -// path and no activate route — a save is an upsert over the single row, and the UI is one form. -// -// A save is validated against the live daemon before it is stored: a wrong URL or a rejected key is a 400 -// with the reason, not a saved row that makes every later panel fail mysteriously. - -export type ProbeResult = - | { ok: true; version: string | null; server: string | null } - | { ok: false; version: string | null; error: string }; - -/** - * Ask a daemon whether it is really there and whether the key works. - * - * Two calls, because they answer different questions: `/health` is unauthenticated, so a failure there means - * the URL is wrong or slskd is down, while `/api/v0/application` failing after it succeeded means the key is - * the problem. Collapsing them would report "daemon unreachable" for a mistyped key. - */ -export async function probe(cfg: SlskdConfig): Promise { - try { - const health = await fetch(`${cfg.base}/health`, { signal: AbortSignal.timeout(5000), redirect: 'manual' }); - if (!health.ok) return { ok: false, version: null, error: `daemon returned ${health.status}` }; - } catch (err) { - return { ok: false, version: null, error: `could not reach the daemon (${String(err)})` }; - } - - try { - const res = await fetch(`${cfg.base}/api/v0/application`, { - headers: { Accept: 'application/json', ...authHeaders(cfg) }, - signal: AbortSignal.timeout(5000), - redirect: 'manual', - }); - if (!res.ok) { - const reason = res.status === 401 || res.status === 403 ? 'API key was rejected' : 'daemon returned'; - return { ok: false, version: null, error: `${reason} (${res.status})` }; - } - const app = (await res.json()) as { - version?: { current?: string; full?: string }; - server?: { state?: string }; - }; - return { ok: true, version: app.version?.full ?? app.version?.current ?? null, server: app.server?.state ?? null }; - } catch (err) { - return { ok: false, version: null, error: String(err) }; - } -} - -/** What the browser is allowed to know about the connection. Never includes the key. */ -async function connectionState(userId: number): Promise { - const connection = await getServiceConnection(userId, 'slskd'); - return Response.json({ configured: !!connection, connection }); -} - -const bad = (error: string, status = 400) => Response.json({ error }, { status }); - -type ConnectionBody = { url?: unknown; apiKey?: unknown }; - -const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); - -/** Save the connection: validated against the live daemon, then stored with the key encrypted. */ -async function save(req: Request, userId: number): Promise { - const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {}; - const url = typeof body.url === 'string' ? normalizeBase(body.url) : ''; - // A blank key on an existing connection means "keep the stored one", so changing the URL does not need - // the key re-typed. `undefined` is what saveServiceConnection reads as leave-alone. - const apiKey = typeof body.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined; - - if (!url) return bad('url is required'); - if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); - - const current = await getServiceConnection(userId, 'slskd'); - if (!apiKey && !current?.hasSecret) return bad('an API key is required'); - - // Probing needs the actual key, so a URL-only save borrows the stored one through the normal read path - // rather than reaching for the ciphertext here. - const stored = apiKey ? null : await getSlskdConfig(userId); - const result = await probe({ base: url, apiKey: apiKey ?? stored?.apiKey ?? null }); - if (!result.ok) return Response.json({ error: result.error }, { status: 400 }); - - const connection = await saveServiceConnection({ - userId, - service: 'slskd', - url, - secret: apiKey, - version: result.version, - }); - invalidateSlskdConfig(userId); - return Response.json({ connection, server: result.server }); -} - -/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */ -export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { - if (subpath && subpath !== '/') return bad('not found', 404); - - if (req.method === 'GET') return connectionState(userId); - if (req.method === 'POST' || req.method === 'PUT') return save(req, userId); - - if (req.method === 'DELETE') { - await deleteServiceConnection(userId, 'slskd'); - invalidateSlskdConfig(userId); - return connectionState(userId); - } - - return bad('method not allowed', 405); -} diff --git a/src/servers/sidecar/slskd/download.ts b/src/servers/sidecar/slskd/download.ts deleted file mode 100644 index 5dc67c62..00000000 --- a/src/servers/sidecar/slskd/download.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getSoulseekBrowseDownload } from 'officerdb'; -import { authHeaders, getSlskdConfig } from './upstream'; - -// Enqueueing downloads out of a cached share tree. -// -// slskd's own enqueue endpoint takes a flat list of {filename, size}, which is fine for search results — -// they arrive as individual files with full paths. A browsed folder is neither: it's a path, and what the -// user means by clicking it is "everything under here". Expanding that into a file list needs the cache, -// so it happens HERE rather than in the browser, which would otherwise have to walk the tree one level -// request at a time and reconstruct paths it only half knows. - -/** - * A folder can be enormous — the root of one measured peer is 284,166 files. Enqueueing that would be a - * misclick that takes the slskd instance down with it, so it's refused with the count rather than - * truncated to a silent partial download. - */ -export const MAX_ENQUEUE = 10_000; - -export class DownloadError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - } -} - -type EnqueueParams = { userId: number; username: string; path: string; file?: string }; - -/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */ -export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise { - const cfg = await getSlskdConfig(userId); - if (!cfg) throw new DownloadError('slskd upstream not configured', 503); - - const files = await getSoulseekBrowseDownload({ userId, username, path, file }); - if (!files.length) throw new DownloadError('nothing to download at that path', 404); - if (files.length > MAX_ENQUEUE) { - throw new DownloadError( - `${files.length.toLocaleString()} files is over the ${MAX_ENQUEUE.toLocaleString()} limit`, - 413, - ); - } - - const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, { - method: 'POST', - headers: { 'content-type': 'application/json', ...authHeaders(cfg) }, - body: JSON.stringify(files), - }); - if (!res.ok) { - const detail = (await res.text().catch(() => '')).slice(0, 200); - throw new DownloadError(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`, 502); - } - - console.log(`[slskd] queued ${files.length} file(s) from ${username}: ${file ?? path}`); - return files.length; -} diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts deleted file mode 100644 index 41596519..00000000 --- a/src/servers/sidecar/slskd/index.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { createSidecarConnector } from '../connect'; -import { resetStaleSoulseekBrowses } from 'officerdb'; -import { getSlskdConfig, stripHopByHop } from './upstream'; -import { handleConfigRoute, probe } from './config'; -import { handleOfficerRoute } from './officer'; -import { API_URL } from '../../officer-url.mjs'; - -// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that -// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd -// (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a -// thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow -// end-to-end without the main server accreting any slskd-specific logic. -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive -// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject -// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim. -// The server listens on a random loopback port, reported to the API on connect so it can route here. -// -// GET /_health ours (probes slskd's /health), not part of the slskd contract -// GET /_config { configured, connection } — the URL and whether a key is stored, never the key -// PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank -// apiKey on an existing connection keeps the stored one -// DEL /_config forget the connection -// -// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept -// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them -// here rather than in the platform API is what lets the main server stay a pure proxy forever. -// -// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind -// loopback only, so its presence is the trust signal — a request without it did not come through the -// platform, and the connection is per-owner data. -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// -// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at -// /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy -// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its -// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP. - -/** Grab an ephemeral free port by briefly binding one and releasing it. */ -function getFreePort(): number { - const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); - const p = probeServer.port; - probeServer.stop(true); - if (p == null) throw new Error('failed to acquire a free port'); - return p; -} - -const port = getFreePort(); - -const server = Bun.serve({ - port, - hostname: '127.0.0.1', - idleTimeout: 255, // browse/search can idle; Bun caps this at 255s - maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses - async fetch(req) { - const url = new URL(req.url); - - const officerUser = req.headers.get('X-Officer-User'); - const userId = Number(officerUser); - if (!officerUser || !Number.isInteger(userId) || userId <= 0) { - return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); - } - - if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { - try { - return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); - } catch (err) { - console.error(`[slskd] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - const cfg = await getSlskdConfig(userId); - - // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a - // configured-but-broken daemon is the whole reason the flag is on the response. - if (url.pathname === '/_health') { - if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); - const started = Date.now(); - const result = await probe(cfg); - const ms = Date.now() - started; - if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); - return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms }); - } - - // Officer-owned routes — answered locally, never proxied (so they work even with slskd down). - if (url.pathname.startsWith('/_officer/')) { - try { - const res = await handleOfficerRoute(req, url); - if (res) return res; - return new Response('not found', { status: 404 }); - } catch (err) { - console.error(`[slskd] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 }); - - const target = `${cfg.base}${url.pathname}${url.search}`; - const method = req.method; - const hasBody = method !== 'GET' && method !== 'HEAD'; - const started = Date.now(); - - const headers = stripHopByHop(req.headers); - if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey); - - // Bun/undici require half-duplex to stream a request body straight through. - const init: RequestInit & { duplex?: 'half' } = { - method, - headers, - body: hasBody ? req.body : undefined, - redirect: 'manual', // a transparent proxy passes 3xx through rather than following them - }; - if (hasBody) init.duplex = 'half'; - - let upstream: Response; - try { - upstream = await fetch(target, init); - } catch { - console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`); - return new Response('slskd upstream unreachable', { status: 502 }); - } - - console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`); - return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); - }, -}); - -// No upstream in the banner: where slskd lives is now per-owner state read from the database per request, -// not a constant this process knows at boot. -console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`); - -// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life -// would spin in the UI forever. Clear it once, at boot, before serving. -resetStaleSoulseekBrowses() - .then((n) => n && console.log(`[slskd] failed ${n} browse snapshot(s) interrupted by restart`)) - .catch((err) => console.error('[slskd] could not reset stale browse snapshots', err)); - -// ── Command handlers ── - -type ReplyFn = (msg: SidecarEvent) => void; - -function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { - switch (cmd.type) { - case 'ping': - reply({ type: 'pong', id: cmd.id }); - break; - default: - reply({ - type: 'error', - id: (cmd as SidecarCommand).id, - error: `Unknown command type: ${(cmd as Record).type}`, - }); - } -} - -// ── Connect to API server ── - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'slskd', - handles: ['slskd'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, - onConnected() { - // Tell the API where our proxy is listening, so it can forward /api/slskd/* here. - connection.send({ type: 'slskd:server', port }); - console.log(`[slskd] reported proxy port ${port} to API`); - }, -}); - -// ── Graceful shutdown ── - -function shutdown(signal: string) { - console.log(`[slskd] ${signal} received, shutting down...`); - try { - server.stop(true); - } catch { - /* already stopped */ - } - connection.destroy(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/slskd/officer.ts b/src/servers/sidecar/slskd/officer.ts deleted file mode 100644 index e2626818..00000000 --- a/src/servers/sidecar/slskd/officer.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { - getSoulseekFavorites, - addSoulseekFavorite, - removeSoulseekFavorite, - getSoulseekBrowseSnapshots, - getSoulseekBrowseSnapshot, - getSoulseekBrowseLevel, - searchSoulseekBrowseTree, - getSoulseekBrowseDirFiles, - deleteSoulseekBrowse, -} from 'officerdb'; -import { startBrowse } from './browse'; -import { enqueueFromCache, DownloadError } from './download'; - -// Officer-owned Soulseek routes — everything slskd itself has no concept of. These are served HERE, by -// the sidecar, not forwarded upstream: the platform API stays a pure auth-and-forward proxy forever, and -// slskd stays unforked. They live under the `/_officer/` prefix, mirroring `/_health`, so they can never -// collide with slskd's own surface (all of which is under `/api/v0/`). -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// CONTRACT (reached from the browser as /api/slskd/_officer/…) -// GET /_officer/favorites → string[] favourited peers, alphabetical -// POST /_officer/favorites { username } → { ok: true } add (idempotent) -// DELETE /_officer/favorites?username= → { ok: true } remove (no-op if absent) -// -// GET /_officer/browse → Snapshot[] every cached share tree -// GET /_officer/browse/ → Snapshot | null one peer's cache state -// POST /_officer/browse/ → 202 { started } kick off a background fetch -// DELETE /_officer/browse/ → { ok: true } drop the cache -// GET /_officer/browse//tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent) -// GET /_officer/browse//tree/search?q=&limit= → { nodes, matched, total, truncated } -// GET /_officer/browse//dirs//files → BrowsedFile[] one folder's files -// POST /_officer/browse//download { path, file? } → { queued } enqueue a folder, or one file -// ───────────────────────────────────────────────────────────────────────────────────────────────── - -// The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We -// bind loopback only, so it's trusted — same arrangement as the music sidecar's per-user state routes. -const userIdOf = (req: Request): number | null => { - const n = Number(req.headers.get('x-officer-user')); - return Number.isInteger(n) && n > 0 ? n : null; -}; - -// Soulseek usernames are opaque to us and may contain spaces, so the only shapes we reject are ones -// no nick can have: empty, absurdly long, or containing control characters. -const USERNAME_MAX = 255; -const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; -const cleanUsername = (v: unknown): string | null => { - if (typeof v !== 'string') return null; - const u = v.trim(); - if (!u || u.length > USERNAME_MAX) return null; - return CONTROL_CHARS.test(u) ? null : u; -}; - -// A level, not a page: once you're inside a folder there's nothing to leaf through, so the UI asks for -// the whole level when it's wide. The cap is set above the widest fan-out measured on a real peer (1,181). -const DIR_PAGE_MAX = 2000; -const DIR_PAGE_DEFAULT = 200; -// A match cap, not a page: every match drags its ancestors along, so the response is up to ~depth times -// this. Truncation is reported in the payload rather than passed off as the whole answer. -const SEARCH_MATCH_MAX = 1000; -const SEARCH_MATCH_DEFAULT = 300; -const clampInt = (raw: string | null, fallback: number, min: number, max: number): number => { - // An absent param has to be caught before Number(): `Number(null)` is 0, not NaN, so it passed the - // finite check and clamped to `min` — which ran the tree search with LIMIT 1 and returned a single - // match, making its siblings look like non-matches in the UI. - if (raw === null || raw.trim() === '') return fallback; - const n = Number(raw); - if (!Number.isFinite(n)) return fallback; - return Math.min(max, Math.max(min, Math.trunc(n))); -}; - -const badRequest = (error: string) => Response.json({ error }, { status: 400 }); -const notFound = () => new Response('not found', { status: 404 }); -const methodNotAllowed = () => new Response('method not allowed', { status: 405 }); - -async function handleFavorites(req: Request, url: URL, userId: number): Promise { - if (req.method === 'GET') return Response.json(await getSoulseekFavorites(userId)); - - if (req.method === 'POST') { - const body = (await req.json().catch(() => null)) as { username?: unknown } | null; - const username = cleanUsername(body?.username); - if (!username) return badRequest('username required'); - await addSoulseekFavorite(userId, username); - return Response.json({ ok: true }); - } - - if (req.method === 'DELETE') { - const username = cleanUsername(url.searchParams.get('username')); - if (!username) return badRequest('username required'); - await removeSoulseekFavorite(userId, username); - return Response.json({ ok: true }); - } - - return methodNotAllowed(); -} - -type BrowseRouteParams = { req: Request; url: URL; userId: number; segments: string[] }; - -async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams): Promise { - // /_officer/browse — every cached tree, for badging the favourites list in one request. - if (segments.length === 1) { - if (req.method !== 'GET') return methodNotAllowed(); - return Response.json(await getSoulseekBrowseSnapshots(userId)); - } - - const username = cleanUsername(decodeURIComponent(segments[1] ?? '')); - if (!username) return badRequest('username required'); - - // /_officer/browse/ - if (segments.length === 2) { - if (req.method === 'GET') return Response.json(await getSoulseekBrowseSnapshot(userId, username)); - if (req.method === 'POST') { - // 202: the fetch takes minutes and outlives this request. Poll the GET for status. - const started = await startBrowse(userId, username); - return Response.json({ started }, { status: 202 }); - } - if (req.method === 'DELETE') { - await deleteSoulseekBrowse(userId, username); - return Response.json({ ok: true }); - } - return methodNotAllowed(); - } - - // /_officer/browse//tree — one level of folders, paged. No `parent` means the roots. - if (segments[2] === 'tree' && segments.length === 3) { - if (req.method !== 'GET') return methodNotAllowed(); - const level = await getSoulseekBrowseLevel({ - userId, - username, - parent: url.searchParams.get('parent') ?? undefined, - limit: clampInt(url.searchParams.get('limit'), DIR_PAGE_DEFAULT, 1, DIR_PAGE_MAX), - offset: clampInt(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER), - }); - return Response.json(level); - } - - // /_officer/browse//tree/search — matches plus their ancestors, as one small tree. Kept off - // the level route because the shape differs: this one is a skeleton spanning depths, not a page. - if (segments[2] === 'tree' && segments.length === 4 && segments[3] === 'search') { - if (req.method !== 'GET') return methodNotAllowed(); - const result = await searchSoulseekBrowseTree({ - userId, - username, - q: url.searchParams.get('q') ?? '', - limit: clampInt(url.searchParams.get('limit'), SEARCH_MATCH_DEFAULT, 1, SEARCH_MATCH_MAX), - }); - return Response.json(result); - } - - // /_officer/browse//download — a folder's whole subtree, or one file inside it. - if (segments[2] === 'download' && segments.length === 3) { - if (req.method !== 'POST') return methodNotAllowed(); - const body = (await req.json().catch(() => null)) as { path?: unknown; file?: unknown } | null; - const path = typeof body?.path === 'string' ? body.path.trim() : ''; - if (!path) return badRequest('path required'); - const file = typeof body?.file === 'string' && body.file.trim() ? body.file : undefined; - try { - const queued = await enqueueFromCache({ userId, username, path, file }); - return Response.json({ queued }); - } catch (err) { - if (err instanceof DownloadError) return Response.json({ error: err.message }, { status: err.status }); - throw err; - } - } - - if (segments[2] !== 'dirs') return notFound(); - - // /_officer/browse//dirs//files - if (segments.length === 5 && segments[4] === 'files') { - if (req.method !== 'GET') return methodNotAllowed(); - const dirId = Number(segments[3]); - if (!Number.isInteger(dirId) || dirId <= 0) return badRequest('invalid directory id'); - const files = await getSoulseekBrowseDirFiles({ userId, username, dirId }); - return files ? Response.json(files) : notFound(); - } - - return notFound(); -} - -/** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */ -export async function handleOfficerRoute(req: Request, url: URL): Promise { - const segments = url.pathname - .replace(/^\/_officer\//, '') - .split('/') - .filter(Boolean); - if (!segments.length) return null; - - const userId = userIdOf(req); - if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); - - if (segments[0] === 'favorites' && segments.length === 1) return handleFavorites(req, url, userId); - if (segments[0] === 'browse') return handleBrowse({ req, url, userId, segments }); - - return null; -} diff --git a/src/servers/sidecar/slskd/upstream.ts b/src/servers/sidecar/slskd/upstream.ts deleted file mode 100644 index d42f47e5..00000000 --- a/src/servers/sidecar/slskd/upstream.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { getServiceCredentials } from 'officerdb'; - -// slskd upstream config + header hygiene for the officer-slskd sidecar. -// -// All knowledge of the slskd instance — its URL and its API key — lives here. The platform API is a thin -// auth+forward proxy and holds NO slskd credentials. -// -// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `service_connections` (see -// databases/officer_db/src/service-connections/queries.ts). It is deliberately no longer read from the -// environment: Bun auto-loads `.env` into every process started in the platform directory, so an -// `SLSKD_API_KEY` there was also sitting in `officer`'s own process.env — a credential that drives the whole -// Soulseek daemon, held by the one process with no code to use it. Nothing in this file reads process.env. - -/** Everything needed to reach the daemon. A candidate being validated has this and nothing else yet. */ -export type SlskdConfig = { base: string; apiKey: string | null }; - -/** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */ -export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); - -// The transfers view polls every couple of seconds and every request needs the key, so the row is cached -// rather than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the -// row in psql, which then takes effect within a minute instead of needing a restart. -const TTL_MS = 60_000; -const cache = new Map(); - -/** - * The owner's slskd connection, or null when nothing is configured — the sidecar then answers 503, and the - * UI turns that into the setup form rather than a wall of empty panels. - */ -export async function getSlskdConfig(userId: number): Promise { - const hit = cache.get(userId); - if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; - - const creds = await getServiceCredentials(userId, 'slskd'); - // `url` became nullable when services that inherit one instance landed (see the schema column note). - // slskd fronts a single daemon and always stores its own, so null here is a malformed row, not an - // inheriting one — treat it as unconfigured rather than reaching for somebody else's URL. - const cfg = creds?.url ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null; - cache.set(userId, { cfg, at: Date.now() }); - return cfg; -} - -/** Drop the cached row — called by the config routes after any save or removal. */ -export function invalidateSlskdConfig(userId: number): void { - cache.delete(userId); -} - -/** The auth header slskd expects, ready to spread. Empty when the daemon has no key configured. */ -export const authHeaders = (cfg: SlskdConfig): Record => - cfg.apiKey ? { 'X-API-Key': cfg.apiKey } : {}; - -// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `host` is dropped so the outgoing -// fetch sets the upstream authority itself; everything else — including our injected X-API-Key — passes. -const HOP_BY_HOP = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'host', -]); - -/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */ -export function stripHopByHop(src: Headers): Headers { - const out = new Headers(); - src.forEach((value, key) => { - const k = key.toLowerCase(); - if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; - out.set(key, value); - }); - return out; -} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 9721ef52..319c094f 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -10,7 +10,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; -import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek'; import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin'; import { appRegistryMetas as giteaMetas } from '../apps/Gitea'; import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; @@ -33,7 +32,6 @@ export const apps = [ ...chatHistoryMetas, ...widgetMetas, ...desktopMetas, - ...soulseekMetas, ...jellyfinMetas, ...giteaMetas, ...invoicesMetas, diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index a02fcc91..eb0eb709 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -120,7 +120,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: ) : isGenerating ? ( ) : ( - + )} {isConnected && onDisconnect && ( - ); - - 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/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx deleted file mode 100644 index e3708744..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx +++ /dev/null @@ -1,614 +0,0 @@ -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(`/slskd/api/v0/searches/${searchId}/responses`), - client.get(`/slskd/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( - `/slskd/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/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx deleted file mode 100644 index b715ae31..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx +++ /dev/null @@ -1,276 +0,0 @@ -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('/slskd/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>('/slskd/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(`/slskd/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(`/slskd/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-white/10 bg-zinc-950 pl-10 pr-3 text-sm text-zinc-100 placeholder:text-zinc-500 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/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx deleted file mode 100644 index 24dfa01e..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SharesBrowser.tsx +++ /dev/null @@ -1,435 +0,0 @@ -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-zinc-100 outline-none placeholder:text-zinc-600" - /> - {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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx deleted file mode 100644 index 10b030f4..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx +++ /dev/null @@ -1,267 +0,0 @@ -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('/slskd/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(`/slskd/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(`/slskd/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(`/slskd/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(`/slskd/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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx deleted file mode 100644 index 1548006d..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type { ServiceConnection } from '../../hooks/useServiceConnection'; -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 '../../hooks/useServiceConnection'; - -// 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('slskd'); - const { data: health } = useServiceHealth('slskd'); - const { save, forget } = useServiceConnectionActions('slskd'); - - 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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx deleted file mode 100644 index 5db167f0..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx +++ /dev/null @@ -1,164 +0,0 @@ -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, - 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('/slskd/api/v0/application'), - client.get('/slskd/api/v0/transfers/downloads'), - client.get('/slskd/api/v0/transfers/uploads'), - client.get('/slskd/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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx deleted file mode 100644 index 5b2ba11f..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx deleted file mode 100644 index f82373f4..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx +++ /dev/null @@ -1,284 +0,0 @@ -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('/slskd/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('/slskd/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(`/slskd/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('/slskd/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(`/slskd/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(`/slskd/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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx deleted file mode 100644 index b3f6dc5b..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx +++ /dev/null @@ -1,145 +0,0 @@ -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('/slskd/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('/slskd/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('/slskd/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/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx deleted file mode 100644 index ea21fcf9..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx +++ /dev/null @@ -1,494 +0,0 @@ -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, - 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 phaseStyle: Record = { - downloading: { label: 'Downloading', dot: 'bg-blue-500', bar: 'bg-blue-500' }, - queued: { label: 'Queued', dot: 'bg-amber-500', bar: 'bg-amber-400' }, - done: { label: 'Done', dot: 'bg-green-500', bar: 'bg-green-500' }, - failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' }, -}; - -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('/slskd/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('/slskd/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( - `/slskd/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(`/slskd/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(`/slskd/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(`/slskd/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(`/slskd/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( - `/slskd/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 style = phaseStyle[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' ? ( - - ) : ( - {style.label} - )} - {row.size > 0 && · {formatSize(row.size)}} - {speed && · {speed}} -
-
- ); - })} - - )} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx deleted file mode 100644 index cb1d37a6..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx +++ /dev/null @@ -1,227 +0,0 @@ -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, - 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 phaseStyle: Record = { - downloading: { label: 'Uploading', dot: 'bg-blue-500', bar: 'bg-blue-500' }, - queued: { label: 'Queued', dot: 'bg-amber-500', bar: 'bg-amber-400' }, - done: { label: 'Done', dot: 'bg-green-500', bar: 'bg-green-500' }, - failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' }, -}; - -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('/slskd/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( - `/slskd/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 style = phaseStyle[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)} - -
-
-
-
-
- {style.label} - {row.size > 0 && · {formatSize(row.size)}} - {speed && · {speed}} -
-
- ); - })} - - )} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx deleted file mode 100644 index 5ae02393..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx +++ /dev/null @@ -1,234 +0,0 @@ -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: 'text-green-500', icon: CircleCheck }; - case 'away': - return { label: 'Away', dot: 'text-amber-500', icon: Clock }; - default: - return { label: 'Offline', dot: 'text-zinc-500', 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-zinc-100' }: StatProps) => ( -
- {label} - {value} -
-); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx deleted file mode 100644 index b32716e9..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 '../../hooks/useServiceConnection'; - -// 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('slskd'); - - // 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/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx deleted file mode 100644 index f92661bf..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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/src/workspaces/officerdev/src/apps/Soulseek/index.ts b/src/workspaces/officerdev/src/apps/Soulseek/index.ts deleted file mode 100644 index 147d607d..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppRegistryMeta } from '../../AppRegistry'; -import { PanelLeft, LayoutGrid } from 'lucide-react'; -import { SoulseekNav } from './SoulseekNav'; -import { SoulseekView } from './SoulseekView'; - -export { SoulseekNav, SoulseekView }; - -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/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts deleted file mode 100644 index 96e11eb4..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ /dev/null @@ -1,391 +0,0 @@ -// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd -// 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 { 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 -}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts deleted file mode 100644 index 831ce928..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekBrowse.ts +++ /dev/null @@ -1,148 +0,0 @@ -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('/slskd/_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(`/slskd/_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(`/slskd/_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(`/slskd/_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( - `/slskd/_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 }>(`/slskd/_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(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`), - enabled: !!username && dirId !== null, - staleTime: Infinity, - }); -} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts deleted file mode 100644 index 00971e08..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts +++ /dev/null @@ -1,47 +0,0 @@ -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('/slskd/_officer/favorites'), - staleTime: 60_000, - }); - - const mutation = useMutation({ - mutationFn: ({ on, username }: { on: boolean; username: string }) => - on - ? post('/slskd/_officer/favorites', { username }) - : del(`/slskd/_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/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts deleted file mode 100644 index 116dc71d..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts +++ /dev/null @@ -1,45 +0,0 @@ -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(`/slskd/api/v0/users/${u}/status`), - get(`/slskd/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, - }); -} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index c5f99187..664baf77 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -37,15 +37,6 @@ export { CodeEditorView } from './apps/CodeEditor'; export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared'; export type { JellyfinSectionId } from './apps/Jellyfin/shared'; -// Same for /soulseek, which also puts the open peer in `?user=`. -export { - DEFAULT_SOULSEEK_SECTION, - soulseekSectionPath, - soulseekUserPath, - isSoulseekSection, -} from './apps/Soulseek/shared'; -export type { SoulseekSectionId } from './apps/Soulseek/shared'; - // Same for /system-monitor, where the param is the scope rather than a section. export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared'; export type { MonitorScope } from './apps/SystemMonitor/shared';