diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 9371a7c6..bc822f41 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -50,7 +50,7 @@ export function App() { } /> - {/* Route pairs, like /offscale (a plugin now) and /photos below: the bare page route exists so the + {/* Route pairs, like /command-hq and /soulseek below: the bare page route exists so the existing links into it keep working, and its `SettingsRoute` guard redirects to the first section. The section is the URL rather than a `*_SELECTED` global, so a settings sub-page is linkable, bookmarkable and cmd-clickable. */} @@ -95,7 +95,7 @@ export function App() { hands the whole subtree to the plugin's own router, which react-router nests natively. */} {installedPlugins.flatMap((plugin) => [ } />, - // The section pair, exactly as the core screens do it (`/photos/:section`): the plugin's + // The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's // panels read `useParams` themselves, so which section is open is the URL rather than state // passed between them. } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx index b4f32f0f..c0526ca3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx @@ -3,7 +3,7 @@ import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /calendar uses the Workspace/Panel system (like /transmission): the calendar list on the left, the +// /calendar uses the Workspace/Panel system (like /command-hq): the calendar list on the left, the // selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the // /api/caldav auth proxy, which holds no DAV credentials of its own. // diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx index 07949de5..2e614a70 100644 --- a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx @@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /gitea uses the Workspace/Panel system (like /transmission): a section nav on the left and the section view +// /gitea uses the Workspace/Panel system (like /command-hq): a section nav on the left and the section view // on the right. Both talk to the officer-gitea sidecar through the /api/gitea auth proxy, which holds no // Gitea credentials of its own — the instance URL and the access token live in the sidecar. // diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx index 820ac7aa..430e4d3d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx @@ -4,7 +4,7 @@ import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, Works import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /invoices uses the Workspace/Panel system (like /transmission and /offscale): the section nav on the +// /invoices uses the Workspace/Panel system (like /command-hq): the section nav on the // left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the // /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the // sidecar and never reach the browser. diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx index 3b193752..dde79a1f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx @@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulsee import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and +// /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. diff --git a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx index ecdd36c2..a737a10b 100644 --- a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx @@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /system-monitor uses the Workspace/Panel system (like /photos): the scope list on the left, the scope +// /system-monitor uses the Workspace/Panel system (like /command-hq): the scope list on the left, the scope // itself on the right. Which scope is open is `:scope` in the URL, not a panel channel. export const SystemMonitorScreen = () => { diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx deleted file mode 100644 index 42959333..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Navigate, useParams } from 'react-router'; -import type { LayoutNode } from 'officerdev'; -import { - WorkspaceView, - DEFAULT_TRANSMISSION_SECTION, - transmissionSectionPath, - isTransmissionSection, -} from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /transmission uses the Workspace/Panel system (like /offscale): a filter nav on the left and the section -// view on the right. Both talk to the officer-transmission sidecar through the /api/transmission auth proxy, -// which holds no Transmission credentials of its own — the RPC URL and any Basic auth live in the sidecar. -// -// The open section is :section in the URL and the torrent filters are query params, so both panels read the -// URL with useParams/useSearchParams instead of passing state between themselves over a channel. This screen -// backs both /transmission and /transmission/:section and is the single place that decides what an absent or -// bogus section means. - -export const TransmissionScreen = () => { - const { section } = useParams(); - const workspace = useDashboardState('screens/transmission', defaultLayout); - - // Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a - // default while the address bar says something else — the nav highlight is derived from the URL. - if (!isTransmissionSection(section)) { - return ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Transmission/defaultLayout.ts deleted file mode 100644 index fb9ebc25..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Transmission/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'transmission-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'transmission-nav', appType: 'transmission-nav' }, size: 22 }, - { node: { type: 'panel', id: 'transmission-view', appType: 'transmission-view' }, size: 78 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/index.tsx b/src/apps/officer-web/Screens/Dashboard/Transmission/index.tsx deleted file mode 100644 index bf72246e..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Transmission/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './TransmissionScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx index d8edc5ef..5f612ace 100644 --- a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx @@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSecti import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /wallet uses the Workspace/Panel system (like /transmission): the wallet list and section nav on the left, +// /wallet uses the Workspace/Panel system (like /command-hq): the wallet list and section nav on the left, // the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth // proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted // there only for the length of an unlock window. diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 9679bc93..083549d1 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -16,7 +16,6 @@ export * from './Contacts'; export * from './Soulseek'; export * from './CommandHQ'; export * from './Jellyfin'; -export * from './Transmission'; export * from './Gitea'; export * from './Invoices'; export * from './Wallet'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index fac7b6f6..8e7afd56 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -26,7 +26,6 @@ const RULES: TitleRule[] = [ { 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('/transmission'), title: 'Transmission' }, { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, { match: (p) => p.startsWith('/invoices'), title: 'Invoices' }, { match: (p) => p.startsWith('/wallet'), title: 'Wallet' }, diff --git a/src/servers/api/transmission/router.ts b/src/servers/api/transmission/router.ts deleted file mode 100644 index 345e1cec..00000000 --- a/src/servers/api/transmission/router.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; - -// /api/transmission/* — auth, then forward to officer-transmission. No routes of its own and no transmission knowledge: -// this file must never grow app logic. -// -// The sidecar owns the Transmission RPC contract, including its session-id handshake, and holds the -// credentials. The platform knows none of it. - -const proxy = createSidecarProxy({ - name: 'transmission', - prefix: '/api/transmission', -}); - -export const transmissionRouter = proxy.router; - -/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getTransmissionServerUrl = proxy.getHttpUrl; diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 7dc95686..4a407856 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -107,9 +107,10 @@ describe('member provisioning is declared for every entry', () => { }); it('never promises accounts for a single-tenant daemon', () => { - // Transmission and slskd have no user concept; claiming otherwise would make the installer try to - // create accounts against an API that does not exist. - for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none'); + // 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 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 68daaeb5..362a868a 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -221,48 +221,11 @@ export const CATALOGUE: CatalogueEntry[] = [ // gets a row there (its install state is `plugin_installs`), so a leftover entry would make the // permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile — // on a server where the plugin is installed and working. - { - id: 'transmission', - ui: { - name: 'Transmission', - icon: 'ArrowDownUp', - color: '#e11d48', - rootRoute: '/transmission', - routes: ['/transmission'], - }, - process: 'officer-transmission', - label: 'Transmission', - summary: 'Torrents, with the daemon Officer talks to over RPC', - members: 'none', - modes: ['existing', 'provisioned'], - permission: 'transmission', - composeTemplate: 'transmission', - existingFields: [ - { - key: 'url', - label: 'Transmission URL', - type: 'url', - required: true, - placeholder: 'https://transmission.example.com or http://10.0.0.5:9091', - }, - { - key: 'path', - label: 'RPC path', - type: 'text', - required: false, - placeholder: '/transmission/rpc', - help: 'Only differs behind a reverse proxy.', - }, - { - key: 'username', - label: 'RPC username', - type: 'text', - required: false, - help: 'Usually blank — Transmission is normally run with no RPC auth.', - }, - { key: 'secret', label: 'RPC password', type: 'secret', required: false }, - ], - }, + // Transmission was here until 2026-08-18, when it became `plugins/transmission/`. + // + // 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: { diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts index c50cd974..1d188011 100644 --- a/src/servers/app-store/installer.test.ts +++ b/src/servers/app-store/installer.test.ts @@ -7,7 +7,7 @@ import { byId } from './catalogue'; // when something goes wrong: resume, blocking, and not doing work twice. const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts' -const transmission = byId('transmission')!; // existing|provisioned, members: 'none' +const slskd = byId('slskd')!; // existing|provisioned, members: 'none' const configOnly = byId('notify')!; // config only /** Records what was actually called, so a test can assert on absence as well as presence. */ @@ -46,7 +46,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(transmission, 'provisioned')).not.toContain('members'); + expect(planSteps(slskd, '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 9a252ff0..b17d3a45 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('transmission')!)).toBe('not-applicable'); + expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable'); }); }); describe('which installed services have work to do', () => { it('skips single-tenant daemons entirely', () => { - const ids = servicesNeedingMemberProvision(['jellyfin', 'transmission', 'slskd', 'memos']).map((e) => e.id); + const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id); expect(ids.sort()).toEqual(['jellyfin', 'memos']); }); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 351333aa..1dd789ea 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -25,7 +25,6 @@ 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 { transmissionRouter } from './api/transmission/router'; // import { invoiceshelfRouter } from './api/invoiceshelf/router'; // import { jellyfinRouter } from './api/jellyfin/router'; // import { walletRouter } from './api/wallet/router'; @@ -151,7 +150,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 - // ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13 // ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13 // ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13 // ['/photos', photosRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index cf3ff55c..a78db3f3 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: 'transmission', - label: 'Transmission', - description: 'Torrent downloads', - kind: 'app', - // api: ['/transmission'], // plugin — switched off 2026-08-13 - api: [], - routes: ['/transmission'], - }, { key: 'soulseek', label: 'Soulseek', diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 756fd01a..26130bc5 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -129,6 +129,26 @@ const BUNDLED: CatalogueEntry[] = [ // No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`. has: { api: true, schema: true, sidecar: true, web: true }, }, + { + appName: 'transmission', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Transmission', + summary: 'Torrent downloads — queue, files, peers and speed, from your own daemon', + color: '#dc2626', + icon: 'Download', + source: 'https://gitea.officer.dev/plugins/transmission.git', + permissions: [ + { + key: 'transmission', + label: 'Transmission', + description: 'Torrent downloads', + }, + ], + // `schema: false` — the connection lives in `service_connections`, a core table. See its manifest. + has: { api: true, schema: false, sidecar: true, web: true }, + }, { appName: 'vault', publisher: 'officerdev', diff --git a/src/servers/sidecar/transmission/config.ts b/src/servers/sidecar/transmission/config.ts deleted file mode 100644 index e3ab8b19..00000000 --- a/src/servers/sidecar/transmission/config.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb'; -import { probe } from './rpc'; -import { - basicAuth, - getTransmissionConfig, - invalidateTransmissionConfig, - normalizeBase, - normalizeRpcPath, -} from './upstream'; - -// `/_config` — where the Transmission daemon is, driven from the app rather than from a shell on the server. -// -// ONE connection, not a registry: nobody runs two Transmission 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. -// -// The URL is the only thing usually needed. Most Transmission daemons run with no RPC auth at all, so -// username/password are optional and an EMPTY username means "no auth" rather than "empty credentials" — -// see basicAuth in upstream.ts, where sending a Basic header anyway makes the daemon reject the request. -// `rpcPath` is there for the reverse-proxy case and defaults to /transmission/rpc. -// -// The password is WRITE-ONLY across this boundary: the GET reports whether one is stored, never its value. -// A save is validated with a live session-get first, so a wrong URL or rejected credentials is a 400 with -// the reason rather than a stored row that makes every later screen fail mysteriously. - -/** What the browser is allowed to know about the connection. Never includes the password. */ -async function connectionState(userId: number): Promise { - const connection = await getServiceConnection(userId, 'transmission'); - return Response.json({ configured: !!connection, connection }); -} - -const bad = (error: string, status = 400) => Response.json({ error }, { status }); - -type ConnectionBody = { url?: unknown; username?: unknown; password?: unknown; rpcPath?: unknown }; - -const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); - -const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); - -/** Save the connection: validated against the live daemon, then stored. */ -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) : ''; - const username = readString(body.username); - const rpcPath = normalizeRpcPath(readString(body.rpcPath)); - // A blank password with a username still set means "keep the stored one", so changing the URL does not - // need the password re-typed. Clearing the username clears the auth entirely. - const password = typeof body.password === 'string' && body.password ? body.password : null; - - if (!url) return bad('url is required'); - if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); - - const current = await getServiceConnection(userId, 'transmission'); - const keepPassword = !password && !!username && username === current?.username && current.hasSecret; - - // Probing needs the real password, so a save that keeps the stored one borrows it through the normal read - // path rather than reaching for the ciphertext here. - const stored = keepPassword ? await getTransmissionConfig(userId) : null; - const auth = keepPassword ? (stored?.auth ?? null) : basicAuth(username || null, password); - - const result = await probe({ base: url, rpcPath, auth }); - if (!result.ok) return bad(result.error ?? 'could not reach the daemon'); - - const connection = await saveServiceConnection({ - userId, - service: 'transmission', - url, - username: username || null, - // undefined keeps the stored password; null clears it, which is what dropping the username means. - secret: keepPassword ? undefined : (password ?? null), - path: rpcPath, - version: result.version ?? null, - }); - invalidateTransmissionConfig(userId); - return Response.json({ connection, rpcVersion: result.rpcVersion ?? null }); -} - -/** `/_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, 'transmission'); - invalidateTransmissionConfig(userId); - return connectionState(userId); - } - - return bad('method not allowed', 405); -} diff --git a/src/servers/sidecar/transmission/index.ts b/src/servers/sidecar/transmission/index.ts deleted file mode 100644 index c3b5684c..00000000 --- a/src/servers/sidecar/transmission/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { createSidecarConnector } from '../connect'; -import { handleConfigRoute } from './config'; -import { handleOfficerRoute } from './routes'; -import { probe } from './rpc'; -import { getTransmissionConfig } from './upstream'; -import { API_URL } from '../../officer-url.mjs'; - -// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and -// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's -// single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder -// (src/servers/api/transmission/router.ts) holding no Transmission credentials. -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding. -// -// GET /_health ours. Probes the daemon with a cheap session-get. -// GET /_config { configured, connection } — where the daemon is, never the password -// PUT /_config { url, username?, password?, rpcPath? } — validated with a live -// session-get, then stored. A blank password keeps the stored one -// DEL /_config forget the connection -// GET /_officer/session full session settings + version + rpc-version -// POST /_officer/session write a partial settings object (whitelisted), returns it read back -// GET /_officer/stats session-stats: cumulative + current-session counters -// GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated -// GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS) -// POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …} -// → {status:'added'|'duplicate', torrent} -// POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-* -// POST /_officer/torrents/set {ids, …whitelisted torrent-set fields} -// POST /_officer/torrents/location {ids, location, move} -// POST /_officer/torrents/rename {id, path, name} -// POST /_officer/torrents/remove {ids, deleteLocalData} -// GET /_officer/free-space?path= free space at a path -// GET /_officer/port-test is the peer port reachable from outside -// POST /_officer/blocklist-update refresh the blocklist, returns the new size -// anything else 404 -// -// There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb -// through one POST body, reports failures as `{"result": ""}` inside a 200, and demands a CSRF token -// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every -// quirk is absorbed here — see rpc.ts. -// -// 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 which daemon to talk to is per-owner data. -// ───────────────────────────────────────────────────────────────────────────────────────────────── - -/** 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', - // A base64 .torrent for a large multi-file release travels in the request body of /torrents/add. - maxRequestBodySize: 256 * 1024 * 1024, - 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(`[transmission] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a - // configured-but-unreachable daemon is the whole reason the flag is on the response. - if (url.pathname === '/_health') { - const cfg = await getTransmissionConfig(userId); - if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); - const started = Date.now(); - const result = await probe(cfg); - return Response.json( - { ...result, configured: true, ms: Date.now() - started }, - { status: result.ok ? 200 : 502 }, - ); - } - - if (url.pathname.startsWith('/_officer/')) { - try { - const res = await handleOfficerRoute(userId, req, url); - if (res) return res; - return Response.json({ error: 'not found' }, { status: 404 }); - } catch (err) { - console.error(`[transmission] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - return Response.json({ error: 'not found' }, { status: 404 }); - }, -}); - -// No upstream in the banner: where the daemon lives is now per-owner state read from the database per -// request, not a constant this process knows at boot. -console.log(`[transmission] listening on 127.0.0.1:${port}`); - -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}`, - }); - } -} - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'transmission', - handles: ['transmission'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, - onConnected() { - connection.send({ type: 'transmission:server', port }); - console.log(`[transmission] reported server port ${port} to API`); - }, -}); - -function shutdown(signal: string) { - console.log(`[transmission] ${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/transmission/routes.ts b/src/servers/sidecar/transmission/routes.ts deleted file mode 100644 index f4ceb0bc..00000000 --- a/src/servers/sidecar/transmission/routes.ts +++ /dev/null @@ -1,367 +0,0 @@ -import type { SessionSettings, SessionStats, Torrent } from './types'; -import { DETAIL_FIELDS, LIST_FIELDS } from './types'; -import { decorate, rpc, TransmissionError } from './rpc'; - -// Officer-owned routes for the transmission sidecar — the entire feature surface lives under /_officer/. -// -// Nothing here is a passthrough. Transmission speaks a single POST endpoint with a `method` field, which is -// a terrible fit for an HTTP cache, for auth scoping and for a browser client: it would mean shipping the -// 409 handshake, the `result: 'success'` convention and the whole method vocabulary into the SPA. So the -// verbs are re-expressed as real REST paths and every quirk is absorbed in rpc.ts. - -export type OfficerContext = { req: Request; url: URL; userId: number }; - -/** 400 with a machine-readable reason. */ -export const badRequest = (error: string) => Response.json({ error }, { status: 400 }); -/** 404 for an unknown /_officer/ path or a missing object. */ -export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 }); -/** 405 when the path exists but the verb doesn't. */ -export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 }); - -async function readJson(req: Request): Promise | null> { - const body = await req.json().catch(() => null); - return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; -} - -/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */ -function readIds(body: Record): number[] | null { - const raw = body['ids']; - if (!Array.isArray(raw)) return null; - const ids = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0); - return ids.length === raw.length ? ids : null; -} - -// Actions that take {ids} and nothing else. Mapping them by name keeps the browser from naming RPC methods. -const ACTIONS: Record = { - start: 'torrent-start', - 'start-now': 'torrent-start-now', - stop: 'torrent-stop', - verify: 'torrent-verify', - reannounce: 'torrent-reannounce', - 'queue-top': 'queue-move-top', - 'queue-up': 'queue-move-up', - 'queue-down': 'queue-move-down', - 'queue-bottom': 'queue-move-bottom', -}; - -// Whitelists, not passthroughs. Transmission silently accepts some nonsense and hard-fails on other, so the -// browser is never allowed to name an arbitrary RPC argument — a typo here is a 400, not a silent no-op. -const TORRENT_SET_FIELDS = new Set([ - 'bandwidthPriority', - 'downloadLimit', - 'downloadLimited', - 'files-unwanted', - 'files-wanted', - 'group', - 'honorsSessionLimits', - 'labels', - 'peer-limit', - 'priority-high', - 'priority-low', - 'priority-normal', - 'queuePosition', - 'seedIdleLimit', - 'seedIdleMode', - 'seedRatioLimit', - 'seedRatioMode', - 'sequential_download', - 'trackerAdd', - 'trackerList', - 'uploadLimit', - 'uploadLimited', -]); - -const SESSION_SET_FIELDS = new Set([ - 'alt-speed-down', - 'alt-speed-enabled', - 'alt-speed-time-begin', - 'alt-speed-time-day', - 'alt-speed-time-enabled', - 'alt-speed-time-end', - 'alt-speed-up', - 'blocklist-enabled', - 'blocklist-url', - 'cache-size-mb', - 'default-trackers', - 'dht-enabled', - 'download-dir', - 'download-queue-enabled', - 'download-queue-size', - 'encryption', - 'idle-seeding-limit', - 'idle-seeding-limit-enabled', - 'incomplete-dir', - 'incomplete-dir-enabled', - 'lpd-enabled', - 'peer-limit-global', - 'peer-limit-per-torrent', - 'peer-port', - 'peer-port-random-on-start', - 'pex-enabled', - 'port-forwarding-enabled', - 'queue-stalled-enabled', - 'queue-stalled-minutes', - 'rename-partial-files', - 'script-torrent-done-enabled', - 'script-torrent-done-filename', - 'seed-queue-enabled', - 'seed-queue-size', - 'seedRatioLimit', - 'seedRatioLimited', - 'speed-limit-down', - 'speed-limit-down-enabled', - 'speed-limit-up', - 'speed-limit-up-enabled', - 'start-added-torrents', - 'trash-original-torrent-files', - 'utp-enabled', -]); - -function pick(body: Record, allowed: Set): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(body)) { - if (allowed.has(key)) out[key] = value; - } - return out; -} - -/** - * Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404. - * - * `userId` was authenticated by the platform proxy and validated at the server boundary — every call below - * needs it, because which daemon to talk to is per-owner state read from the database. - */ -export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise { - const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean); - if (segments.length === 0) return null; - - const ctx: OfficerContext = { req, url, userId }; - - try { - switch (segments[0]) { - case 'session': - return await handleSession(ctx, segments.slice(1)); - case 'stats': - return await handleStats(ctx); - case 'torrents': - return await handleTorrents(ctx, segments.slice(1)); - case 'free-space': - return await handleFreeSpace(ctx); - case 'port-test': - return await handlePortTest(ctx); - case 'blocklist-update': - return await handleBlocklistUpdate(ctx); - default: - return null; - } - } catch (err) { - if (err instanceof TransmissionError) return Response.json({ error: err.message }, { status: err.status }); - throw err; - } -} - -async function handleSession(ctx: OfficerContext, rest: string[]): Promise { - if (rest.length > 0) return null; - - if (ctx.req.method === 'GET') { - // No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it. - const session = await rpc(ctx.userId, 'session-get'); - return Response.json({ session }); - } - - if (ctx.req.method === 'POST') { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - const args = pick(body, SESSION_SET_FIELDS); - if (Object.keys(args).length === 0) return badRequest('no writable session fields in body'); - await rpc(ctx.userId, 'session-set', args); - // Read back rather than echoing the request: Transmission clamps and normalises several of these - // (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding. - const session = await rpc(ctx.userId, 'session-get'); - return Response.json({ session }); - } - - return methodNotAllowed(); -} - -async function handleStats(ctx: OfficerContext): Promise { - if (ctx.req.method !== 'GET') return methodNotAllowed(); - const stats = await rpc(ctx.userId, 'session-stats'); - return Response.json({ stats }); -} - -async function handleFreeSpace(ctx: OfficerContext): Promise { - if (ctx.req.method !== 'GET') return methodNotAllowed(); - const path = ctx.url.searchParams.get('path'); - if (!path) return badRequest('path query parameter is required'); - const result = await rpc<{ path: string; 'size-bytes': number }>(ctx.userId, 'free-space', { path }); - return Response.json({ path: result.path, bytes: result['size-bytes'] }); -} - -// GET: it asks the daemon whether the peer port is reachable and returns the answer. Nothing changes, -// on this side or Transmission's. Blocklist-update below stays POST because it genuinely refetches and -// replaces the list — the two look alike and are not. -async function handlePortTest(ctx: OfficerContext): Promise { - if (ctx.req.method !== 'GET') return methodNotAllowed(); - const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, 'port-test'); - return Response.json({ open: result['port-is-open'] }); -} - -async function handleBlocklistUpdate(ctx: OfficerContext): Promise { - if (ctx.req.method !== 'POST') return methodNotAllowed(); - const result = await rpc<{ 'blocklist-size': number }>(ctx.userId, 'blocklist-update'); - return Response.json({ size: result['blocklist-size'] }); -} - -async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise { - const { req } = ctx; - - // GET /_officer/torrents — the list poll. - if (rest.length === 0 && req.method === 'GET') { - const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { fields: [...LIST_FIELDS] }); - return Response.json({ torrents: result.torrents.map(decorate) }); - } - - if (rest.length === 1) { - const [segment] = rest as [string]; - - if (req.method === 'POST') { - switch (segment) { - case 'add': - return await addTorrent(ctx); - case 'action': - return await runAction(ctx); - case 'set': - return await setTorrent(ctx); - case 'location': - return await setLocation(ctx); - case 'rename': - return await renamePath(ctx); - case 'remove': - return await removeTorrents(ctx); - } - } - - // GET /_officer/torrents/:id — the detail poll, one torrent, with the expensive arrays. - const id = Number(segment); - if (!Number.isInteger(id) || id <= 0) return notFound(); - if (req.method !== 'GET') return methodNotAllowed(); - - const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { - ids: [id], - fields: [...LIST_FIELDS, ...DETAIL_FIELDS], - }); - const torrent = result.torrents[0]; - if (!torrent) return notFound('torrent not found'); - return Response.json({ torrent: decorate(torrent) }); - } - - return null; -} - -async function addTorrent(ctx: OfficerContext): Promise { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - - const args: Record = {}; - // Exactly one source. `metainfo` is a base64 .torrent; `filename` is a magnet link or an http(s) URL. - if (typeof body['metainfo'] === 'string' && body['metainfo']) args['metainfo'] = body['metainfo']; - else if (typeof body['filename'] === 'string' && body['filename']) args['filename'] = body['filename']; - else return badRequest('either metainfo or filename is required'); - - if (typeof body['downloadDir'] === 'string' && body['downloadDir']) args['download-dir'] = body['downloadDir']; - if (Array.isArray(body['labels'])) args['labels'] = body['labels'].filter((l) => typeof l === 'string'); - if (typeof body['paused'] === 'boolean') args['paused'] = body['paused']; - if (typeof body['bandwidthPriority'] === 'number') args['bandwidthPriority'] = body['bandwidthPriority']; - if (typeof body['sequentialDownload'] === 'boolean') args['sequential_download'] = body['sequentialDownload']; - - const result = await rpc<{ - 'torrent-added'?: { id: number; name: string; hashString: string }; - 'torrent-duplicate'?: { id: number; name: string; hashString: string }; - }>(ctx.userId, 'torrent-add', args); - - const added = result['torrent-added']; - const duplicate = result['torrent-duplicate']; - // A duplicate is not an error — Transmission reports the torrent you already had, and the UI counts it - // separately so a batch add can say "3 added, 1 already present" instead of failing opaquely. - if (duplicate) return Response.json({ status: 'duplicate', torrent: duplicate }); - if (added) return Response.json({ status: 'added', torrent: added }); - return Response.json({ status: 'added', torrent: null }); -} - -async function runAction(ctx: OfficerContext): Promise { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - - const action = typeof body['action'] === 'string' ? body['action'] : ''; - const method = ACTIONS[action]; - if (!method) return badRequest(`unknown action "${action}"`); - - const ids = readIds(body); - if (!ids) return badRequest('ids must be an array of positive integers'); - if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); - - await rpc(ctx.userId, method, { ids }); - return Response.json({ ok: true, affected: ids.length }); -} - -async function setTorrent(ctx: OfficerContext): Promise { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - - const ids = readIds(body); - if (!ids) return badRequest('ids must be an array of positive integers'); - - const fields = pick(body, TORRENT_SET_FIELDS); - if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body'); - if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); - - await rpc(ctx.userId, 'torrent-set', { ids, ...fields }); - return Response.json({ ok: true, affected: ids.length }); -} - -async function setLocation(ctx: OfficerContext): Promise { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - - const ids = readIds(body); - if (!ids) return badRequest('ids must be an array of positive integers'); - const location = typeof body['location'] === 'string' ? body['location'].trim() : ''; - if (!location) return badRequest('location is required'); - if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); - - // `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting - // this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted. - const move = body['move'] === true; - await rpc(ctx.userId, 'torrent-set-location', { ids, location, move }); - return Response.json({ ok: true, affected: ids.length }); -} - -async function renamePath(ctx: OfficerContext): Promise { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - - const id = Number(body['id']); - if (!Number.isInteger(id) || id <= 0) return badRequest('id must be a positive integer'); - const path = typeof body['path'] === 'string' ? body['path'] : ''; - const name = typeof body['name'] === 'string' ? body['name'].trim() : ''; - if (!path) return badRequest('path is required'); - if (!name) return badRequest('name is required'); - - // torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it. - await rpc(ctx.userId, 'torrent-rename-path', { ids: [id], path, name }); - return Response.json({ ok: true }); -} - -async function removeTorrents(ctx: OfficerContext): Promise { - const body = await readJson(ctx.req); - if (!body) return badRequest('expected a JSON object body'); - - const ids = readIds(body); - if (!ids) return badRequest('ids must be an array of positive integers'); - if (ids.length === 0) return Response.json({ ok: true, affected: 0 }); - - const deleteLocalData = body['deleteLocalData'] === true; - await rpc(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData }); - return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData }); -} diff --git a/src/servers/sidecar/transmission/rpc.ts b/src/servers/sidecar/transmission/rpc.ts deleted file mode 100644 index 477569bf..00000000 --- a/src/servers/sidecar/transmission/rpc.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { Torrent, TrackerStat } from './types'; -import type { TransmissionConfig } from './upstream'; -import { getTransmissionConfig } from './upstream'; - -// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the -// wire-level quirks are handled exactly once: -// -// • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with -// `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request -// carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you -// can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this -// sidecar exists: a client that handles it naively works until the daemon is restarted and then fails -// in a way that looks like an auth bug. -// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries -// `{"result": "some error string"}`; only `result === 'success'` means it worked. -// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see basicAuth in upstream.ts. -// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so -// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed. - -const DEFAULT_TIMEOUT_MS = 30_000; - -/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */ -export class TransmissionError extends Error { - constructor( - readonly status: number, - message: string, - ) { - super(message); - this.name = 'TransmissionError'; - } -} - -// The CSRF token for a daemon session, keyed by endpoint rather than by owner: the token belongs to the -// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon -// cannot carry a token that daemon never issued. -const sessionIds = new Map(); - -type RpcResponse = { result: string; arguments?: T }; - -/** Issue one RPC call against the owner's configured daemon. */ -export async function rpc(userId: number, method: string, args: Record = {}): Promise { - const cfg = await getTransmissionConfig(userId); - if (!cfg) throw new TransmissionError(503, 'transmission is not connected'); - return call(cfg, method, args, true); -} - -/** The same call against a connection that may not be stored yet — used to validate one before saving it. */ -export async function rpcWith( - cfg: TransmissionConfig, - method: string, - args: Record = {}, -): Promise { - return call(cfg, method, args, true); -} - -async function call( - cfg: TransmissionConfig, - method: string, - args: Record, - mayRetry: boolean, -): Promise { - const endpoint = `${cfg.base}${cfg.rpcPath}`; - - const headers: Record = { 'Content-Type': 'application/json' }; - const sessionId = sessionIds.get(endpoint); - if (sessionId) headers['X-Transmission-Session-Id'] = sessionId; - if (cfg.auth) headers['Authorization'] = cfg.auth; - - let res: Response; - try { - res = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify({ method, arguments: args }), - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), - }); - } catch (err) { - const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable'; - throw new TransmissionError(502, `transmission upstream ${reason}`); - } - - if (res.status === 409) { - const fresh = res.headers.get('X-Transmission-Session-Id'); - if (fresh && mayRetry) { - sessionIds.set(endpoint, fresh); - return call(cfg, method, args, false); - } - throw new TransmissionError(502, 'transmission rejected the session id handshake'); - } - - if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials'); - if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`); - - const body = (await res.json().catch(() => null)) as RpcResponse | null; - if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body'); - // A 200 with a non-'success' result is the normal way Transmission reports a failure. - if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure'); - - return (body.arguments ?? {}) as T; -} - -/** Reachability + credential probe, used by /_health and before a connection is saved. */ -export async function probe( - cfg: TransmissionConfig, -): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> { - try { - const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', { - fields: ['version', 'rpc-version'], - }); - return { ok: true, version: args.version, rpcVersion: args['rpc-version'] }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} - -// ── Derived fields ──────────────────────────────────────────────────────────────────────────────── - -// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same -// operator to a human grouping their torrents, and showing them as two entries makes the filter useless. -const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']); - -const ANNOUNCE_STATE: Record = { - 0: 'Inactive', - 1: 'Waiting', - 2: 'Queued', - 3: 'Announcing', -}; - -/** `udp://tr.example.org:1337/announce` → `example.org`. Empty string when there is no tracker at all. */ -export function trackerHost(stats: TrackerStat[]): string { - const primary = stats.find((t) => !t.isBackup) ?? stats[0]; - if (!primary) return ''; - - const raw = primary.host || primary.announce; - let host: string; - try { - host = new URL(raw.includes('://') ? raw : `http://${raw}`).hostname; - } catch { - host = raw - .replace(/^[a-z]+:\/\//i, '') - .split('/')[0]! - .split(':')[0]!; - } - - const parts = host.split('.'); - // Only strip a prefix when something recognisable remains — `t.co` must not become `co`. - if (parts.length > 2 && IGNORED_TRACKER_PREFIXES.has(parts[0]!.toLowerCase())) parts.shift(); - return parts.join('.'); -} - -function trackerStatus(stats: TrackerStat[]): string { - const primary = stats.find((t) => !t.isBackup) ?? stats[0]; - if (!primary) return ''; - if (primary.lastAnnounceSucceeded === false && primary.lastAnnounceResult) return primary.lastAnnounceResult; - return ANNOUNCE_STATE[primary.announceState] ?? ''; -} - -/** Attach the `officer*` fields the UI groups and filters by. Mutating the parsed body is fine — it's ours. */ -export function decorate(torrent: Torrent): Torrent { - const stats = torrent.trackerStats ?? []; - torrent.officerTrackerHost = trackerHost(stats); - torrent.officerTrackerStatus = trackerStatus(stats); - torrent.officerTrackerErrors = stats.filter((t) => t.lastAnnounceSucceeded === false && t.hasAnnounced).length; - return torrent; -} diff --git a/src/servers/sidecar/transmission/types.ts b/src/servers/sidecar/transmission/types.ts deleted file mode 100644 index e8e5db1e..00000000 --- a/src/servers/sidecar/transmission/types.ts +++ /dev/null @@ -1,244 +0,0 @@ -// Wire types for the Transmission RPC surface this sidecar uses. -// -// Field names are Transmission's OWN, verbatim — including its inconsistent casing (`percentDone` next to -// `begin_piece`, `peer-limit`, `sequential_download`). That is deliberate: renaming them would mean every -// future field needs a translation entry, and a name that doesn't appear in Transmission's rpc-spec.md is a -// name you cannot grep for when something misbehaves. The only fields we invent are the `officer*` derived -// ones at the bottom of Torrent, which are prefixed so they can never be mistaken for upstream data. - -/** Transmission's torrent status enum (rpc-spec.md §3.3). */ -export const TorrentStatus = { - Stopped: 0, - CheckWait: 1, - Check: 2, - DownloadWait: 3, - Download: 4, - SeedWait: 5, - Seed: 6, -} as const; - -export type TrackerStat = { - announce: string; - announceState: number; - downloadCount: number; - hasAnnounced: boolean; - hasScraped: boolean; - host: string; - id: number; - isBackup: boolean; - lastAnnouncePeerCount: number; - lastAnnounceResult: string; - lastAnnounceSucceeded: boolean; - lastAnnounceTime: number; - lastScrapeResult: string; - lastScrapeSucceeded: boolean; - lastScrapeTime: number; - leecherCount: number; - nextAnnounceTime: number; - scrape: string; - seederCount: number; - tier: number; -}; - -export type TorrentFile = { - bytesCompleted: number; - length: number; - name: string; -}; - -export type TorrentFileStat = { - bytesCompleted: number; - priority: number; - wanted: boolean; -}; - -export type TorrentPeer = { - address: string; - clientName: string; - clientIsChoked: boolean; - clientIsInterested: boolean; - flagStr: string; - isDownloadingFrom: boolean; - isEncrypted: boolean; - isIncoming: boolean; - isUploadingTo: boolean; - isUTP: boolean; - peerIsChoked: boolean; - peerIsInterested: boolean; - port: number; - progress: number; - rateToClient: number; - rateToPeer: number; -}; - -export type PeersFrom = { - fromCache: number; - fromDht: number; - fromIncoming: number; - fromLpd: number; - fromLtep: number; - fromPex: number; - fromTracker: number; -}; - -export type Torrent = { - id: number; - name: string; - status: number; - totalSize: number; - sizeWhenDone: number; - leftUntilDone: number; - haveValid: number; - percentDone: number; - metadataPercentComplete: number; - recheckProgress?: number; - downloadedEver: number; - uploadedEver: number; - corruptEver?: number; - uploadRatio: number; - rateDownload: number; - rateUpload: number; - eta: number; - peersSendingToUs: number; - peersGettingFromUs: number; - addedDate: number; - doneDate: number; - activityDate: number; - secondsSeeding: number; - downloadDir: string; - bandwidthPriority: number; - queuePosition: number; - isPrivate: boolean; - labels: string[]; - error: number; - errorString: string; - pieceCount: number; - pieceSize: number; - magnetLink: string; - group: string; - 'file-count': number; - trackerStats: TrackerStat[]; - trackerList?: string; - // Per-torrent limits — the fields the "Other settings" dialog reads and writes. - honorsSessionLimits: boolean; - downloadLimited: boolean; - downloadLimit: number; - uploadLimited: boolean; - uploadLimit: number; - 'peer-limit': number; - seedRatioMode: number; - seedRatioLimit: number; - seedIdleMode: number; - seedIdleLimit: number; - sequential_download?: boolean; - // Detail-only fields — present when fetched through /_officer/torrents/:id. - hashString?: string; - comment?: string; - creator?: string; - dateCreated?: number; - maxConnectedPeers?: number; - files?: TorrentFile[]; - fileStats?: TorrentFileStat[]; - peers?: TorrentPeer[]; - peersFrom?: PeersFrom; - // ── Derived by the sidecar, never sent by Transmission ── - /** - * The primary tracker's host with its port and any ignorable subdomain prefix stripped - * (`udp://tr.example.org:1337/announce` → `example.org`). The UI groups by this, so the normalisation - * has to be one implementation, not one per caller. - */ - officerTrackerHost: string; - /** Human-readable announce state of the primary tracker, for the "Tracker Status" column. */ - officerTrackerStatus: string; - /** Count of trackers that have reported an error, so the UI can flag a torrent without walking the array. */ - officerTrackerErrors: number; -}; - -export type SessionSettings = Record & { - version: string; - 'rpc-version': number; - 'download-dir': string; -}; - -export type SessionStats = { - activeTorrentCount: number; - pausedTorrentCount: number; - torrentCount: number; - downloadSpeed: number; - uploadSpeed: number; - 'cumulative-stats': StatsBucket; - 'current-stats': StatsBucket; -}; - -export type StatsBucket = { - downloadedBytes: number; - uploadedBytes: number; - filesAdded: number; - sessionCount: number; - secondsActive: number; -}; - -/** The fields the torrent list needs. Mirrors what the list UI actually renders — nothing speculative. */ -export const LIST_FIELDS = [ - 'activityDate', - 'addedDate', - 'bandwidthPriority', - 'corruptEver', - 'doneDate', - 'downloadDir', - 'downloadedEver', - 'downloadLimit', - 'downloadLimited', - 'error', - 'errorString', - 'eta', - 'file-count', - 'group', - 'haveValid', - 'honorsSessionLimits', - 'id', - 'isPrivate', - 'labels', - 'leftUntilDone', - 'magnetLink', - 'metadataPercentComplete', - 'name', - 'peer-limit', - 'peersGettingFromUs', - 'peersSendingToUs', - 'percentDone', - 'pieceCount', - 'pieceSize', - 'queuePosition', - 'rateDownload', - 'rateUpload', - 'secondsSeeding', - 'seedIdleLimit', - 'seedIdleMode', - 'seedRatioLimit', - 'seedRatioMode', - 'sequential_download', - 'sizeWhenDone', - 'status', - 'totalSize', - 'trackerList', - 'trackerStats', - 'uploadedEver', - 'uploadLimit', - 'uploadLimited', - 'uploadRatio', -] as const; - -/** Extra fields only the detail pane needs — big arrays we refuse to pull for every row on every poll. */ -export const DETAIL_FIELDS = [ - 'comment', - 'creator', - 'dateCreated', - 'fileStats', - 'files', - 'hashString', - 'maxConnectedPeers', - 'peers', - 'peersFrom', - 'recheckProgress', -] as const; diff --git a/src/servers/sidecar/transmission/upstream.ts b/src/servers/sidecar/transmission/upstream.ts deleted file mode 100644 index 7a0ed8cf..00000000 --- a/src/servers/sidecar/transmission/upstream.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { getServiceCredentials } from 'officerdb'; - -// Transmission upstream config for the officer-transmission sidecar. -// -// All knowledge of the daemon — its URL, its RPC path and its credentials — lives here. The platform API is -// a thin auth+forward proxy and holds NO Transmission credentials. -// -// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored in `service_connections` (see -// databases/officer_db/src/service-connections/queries.ts), no longer read from the environment: Bun -// auto-loads `.env` into every process started in the platform directory, so TRANSMISSION_* was also -// sitting in `officer`'s own process.env, and pointing Officer at a daemon meant editing a file on the -// server. Nothing in this file reads process.env. - -/** Everything needed to make one RPC call. A candidate being validated has this and nothing else yet. */ -export type TransmissionConfig = { base: string; rpcPath: string; auth: string | null }; - -/** Trailing slashes off, so `${base}${rpcPath}` never doubles the separator. */ -export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); - -/** - * The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can - * mount it anywhere, so it is stored per connection rather than hardcoded. - */ -export const normalizeRpcPath = (raw: string | null | undefined): string => { - const path = raw?.trim(); - if (!path) return '/transmission/rpc'; - return path.startsWith('/') ? path : `/${path}`; -}; - -/** - * The `Authorization: Basic …` header value, or null when the daemon has no auth configured. - * - * Transmission treats an empty username as "no authentication required" — sending an empty Basic header in - * that case is not merely useless, it makes the daemon reject the request. So this returns null unless a - * username is actually set, which is why the stored username is nullable rather than defaulting to ''. - */ -export function basicAuth(username: string | null, password: string | null): string | null { - const user = username?.trim(); - if (!user) return null; - return `Basic ${Buffer.from(`${user}:${password ?? ''}`).toString('base64')}`; -} - -// The torrent list polls every couple of seconds, 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 Transmission connection, or null when nothing is configured — the sidecar then answers 503, - * and the UI turns that into the setup form rather than an empty torrent list. - */ -export async function getTransmissionConfig(userId: number): Promise { - const hit = cache.get(userId); - if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; - - const creds = await getServiceCredentials(userId, 'transmission'); - // `url` became nullable when services that inherit one instance landed (see the schema column note). - // Transmission fronts a single daemon and always stores its own, so null here is a malformed row. - const cfg = creds?.url - ? { - base: normalizeBase(creds.url), - rpcPath: normalizeRpcPath(creds.path), - auth: basicAuth(creds.username, 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 invalidateTransmissionConfig(userId: number): void { - cache.delete(userId); -} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index ecca72b0..9721ef52 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -12,7 +12,6 @@ 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 transmissionMetas } from '../apps/Transmission'; import { appRegistryMetas as giteaMetas } from '../apps/Gitea'; import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; import { appRegistryMetas as walletMetas } from '../apps/Wallet'; @@ -36,7 +35,6 @@ export const apps = [ ...desktopMetas, ...soulseekMetas, ...jellyfinMetas, - ...transmissionMetas, ...giteaMetas, ...invoicesMetas, ...walletMetas, diff --git a/src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx b/src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx deleted file mode 100644 index 91996cd4..00000000 --- a/src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx +++ /dev/null @@ -1,219 +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 Transmission daemon to Officer, from the app. -// -// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place -// of whatever section the URL asks for while nothing is connected, and /transmission/connection 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. -// -// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most -// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than -// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful -// about that distinction because Transmission rejects a request carrying an empty Basic header. - -const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; - -/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */ -const DEFAULT_URL = 'http://localhost:9091'; - -const URL_HINT = - "The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " + - '— so localhost here means the machine Officer runs on.'; - -const AUTH_HINT = - 'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' + - 'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.'; - -const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.'; - -type FieldProps = { - label: string; - hint?: string; - value: string; - onChange: (value: string) => void; - placeholder: string; - type?: string; - autoFocus?: boolean; -}; - -const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => ( - -); - -type SaveInput = Record & { url: string }; - -export const ConnectionView = () => { - const { data, isLoading } = useServiceConnection('transmission'); - const { data: health } = useServiceHealth('transmission'); - const { save, forget } = useServiceConnectionActions('transmission'); - - const connection = data?.connection ?? null; - - const [url, setUrl] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [rpcPath, setRpcPath] = useState(''); - const [showAdvanced, setShowAdvanced] = useState(false); - const [error, setError] = useState(''); - - // Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the - // same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it. - useEffect(() => { - setUrl(connection?.url ?? ''); - setUsername(connection?.username ?? ''); - setRpcPath(connection?.path ?? ''); - setPassword(''); - setShowAdvanced(!!connection?.username); - }, [connection?.id]); - - const submit = async () => { - setError(''); - try { - await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() }); - setPassword(''); - } catch (err) { - setError(serviceErrorMessage(err)); - } - }; - - const remove = async () => { - setError(''); - try { - await forget.mutateAsync(); - } catch (err) { - setError(serviceErrorMessage(err)); - } - }; - - if (isLoading) { - return ( -
- Loading… -
- ); - } - - return ( -
-
-
-
- -
-
-

Transmission daemon

-

- {connection - ? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.' - : 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'} -

-
-
- - {connection && } - -
- - - - - {showAdvanced && ( - <> - - - - - )} - - {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 ? ` · Transmission ${connection.version}` : ''} -
- {!health?.ok && health?.error &&
{health.error}
} -
-
-); diff --git a/src/workspaces/officerdev/src/apps/Transmission/SettingsView.tsx b/src/workspaces/officerdev/src/apps/Transmission/SettingsView.tsx deleted file mode 100644 index 927579a2..00000000 --- a/src/workspaces/officerdev/src/apps/Transmission/SettingsView.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import type { ReactNode } from 'react'; -import { useState } from 'react'; -import { Loader2, Plug, ShieldCheck } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Textarea } from '@/components/ui/textarea'; -import { useMaintenance, useSession } from './useTransmissionData'; - -// The daemon's settings, as one form with a draft. -// -// Only the fields you actually changed are sent — session-set is a partial update, and posting the whole -// object back would race the 5-second poll and re-apply stale values you never touched. The sidecar reads -// the settings back after writing because Transmission clamps several of them, so the form snaps to what -// the daemon really holds rather than to what you typed. - -export const SettingsView = () => { - const { session, isLoading, save } = useSession(); - const { portTest, updateBlocklist } = useMaintenance(); - const [draft, setDraft] = useState>({}); - - if (isLoading && !session) { - return ( -
- - Loading settings… -
- ); - } - - if (!session) { - return ( -
- Could not read the session settings. -
- ); - } - - const get = (key: string, fallback: T): T => - key in draft ? (draft[key] as T) : ((session[key] as T) ?? fallback); - const set = (key: string, value: unknown) => setDraft((prev) => ({ ...prev, [key]: value })); - - const dirty = Object.keys(draft).length > 0; - - const apply = async () => { - await save.mutateAsync(draft); - setDraft({}); - }; - - const bool = (key: string, label: string, hint?: string) => ( - set(key, v)} label={label} hint={hint} /> - ); - - const number = (key: string, label: string, unit?: string, min = 0) => ( - - set(key, Number(ev.target.value))} - className="w-32" - /> - - ); - - const text = (key: string, label: string, placeholder?: string) => ( -
- - set(key, ev.target.value)} - className="font-mono text-xs" - /> -
- ); - - return ( -
- - - Downloads - Bandwidth - Network - Queue - Other - - -
- -
- {text('download-dir', 'Download directory', '/downloads')} - {bool('incomplete-dir-enabled', 'Keep incomplete files somewhere else')} - {get('incomplete-dir-enabled', false) && text('incomplete-dir', 'Incomplete directory')} - {bool( - 'rename-partial-files', - 'Append .part to incomplete files', - 'Stops media scanners picking up half-downloaded files.', - )} -
- -
- {bool('start-added-torrents', 'Start torrents as soon as they are added')} - {bool('trash-original-torrent-files', 'Delete the .torrent file after adding')} -
- -
- {bool('seedRatioLimited', 'Stop seeding at a ratio')} - {get('seedRatioLimited', false) && ( - - set('seedRatioLimit', Number(ev.target.value))} - className="w-32" - /> - - )} - {bool('idle-seeding-limit-enabled', 'Stop seeding when idle')} - {get('idle-seeding-limit-enabled', false) && number('idle-seeding-limit', 'Idle for', 'minutes')} -
-
- - -
- {bool('speed-limit-down-enabled', 'Limit download speed')} - {get('speed-limit-down-enabled', false) && number('speed-limit-down', 'Download', 'kB/s')} - {bool('speed-limit-up-enabled', 'Limit upload speed')} - {get('speed-limit-up-enabled', false) && number('speed-limit-up', 'Upload', 'kB/s')} -
- -
- {bool('alt-speed-enabled', 'Use the alternative limits now')} - {number('alt-speed-down', 'Download', 'kB/s')} - {number('alt-speed-up', 'Upload', 'kB/s')} - {bool('alt-speed-time-enabled', 'Switch automatically on a schedule')} - {get('alt-speed-time-enabled', false) && ( - <> - set('alt-speed-time-begin', v)} - /> - set('alt-speed-time-end', v)} - /> - set('alt-speed-time-day', v)} /> - - )} -
-
- - -
- {number('peer-port', 'Peer port', undefined, 1)} - {bool('peer-port-random-on-start', 'Pick a random port on each start')} - {bool('port-forwarding-enabled', 'Ask the router to forward it (UPnP / NAT-PMP)')} -
- -
-
- -
- {number('peer-limit-global', 'Maximum peers overall', undefined, 1)} - {number('peer-limit-per-torrent', 'Maximum peers per torrent', undefined, 1)} - - - -
- -
- {bool('pex-enabled', 'Peer exchange (PEX)')} - {bool('dht-enabled', 'Distributed hash table (DHT)')} - {bool('lpd-enabled', 'Local peer discovery')} - {bool('utp-enabled', 'µTP — yields to other traffic on your connection')} -
- -
- {bool('blocklist-enabled', 'Use a blocklist')} - {text('blocklist-url', 'Blocklist URL')} -
- - - {Number(session['blocklist-size'] ?? 0).toLocaleString()} rules loaded - -
-
-
- - -
- {bool('download-queue-enabled', 'Limit how many torrents download at once')} - {get('download-queue-enabled', false) && number('download-queue-size', 'At most', 'torrents', 1)} -
-
- {bool('seed-queue-enabled', 'Limit how many torrents seed at once')} - {get('seed-queue-enabled', false) && number('seed-queue-size', 'At most', 'torrents', 1)} -
-
- {bool('queue-stalled-enabled', 'Treat torrents with no activity as stalled')} - {get('queue-stalled-enabled', false) && number('queue-stalled-minutes', 'After', 'minutes', 1)} -
-
- - -
{number('cache-size-mb', 'Disk cache', 'MB')}
- -
- {bool('script-torrent-done-enabled', 'Run a script when a torrent finishes')} - {get('script-torrent-done-enabled', false) && - text('script-torrent-done-filename', 'Script path', '/etc/transmission/done.sh')} -
- -
-