add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getTransmissionServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/transmission/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||
// subpath + query + body to the officer-transmission sidecar, which OWNS the Transmission contract and holds
|
||||
// the daemon credentials.
|
||||
//
|
||||
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
||||
// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw
|
||||
// would push the handshake into the browser. The full contract is documented at the top of
|
||||
// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission
|
||||
// logic.
|
||||
|
||||
export const transmissionRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/transmission';
|
||||
|
||||
transmissionRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getTransmissionServerUrl();
|
||||
if (!baseUrl) return ctx.text('transmission sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||
// loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[transmission] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('transmission sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-transmission sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||
// connect. We remember it so `/api/transmission/*` always forwards to the current sidecar. The platform
|
||||
// holds NO knowledge of Transmission itself — not its URL, and not its credentials.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('transmission:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[transmission] sidecar registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getTransmissionServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { musicRouter } from './api/music/router';
|
||||
import { vaultRouter } from './api/vault/router';
|
||||
import { slskdRouter } from './api/slskd/router';
|
||||
import { headscaleRouter } from './api/headscale/router';
|
||||
import { transmissionRouter } from './api/transmission/router';
|
||||
import { vpnRouter } from './api/vpn/router';
|
||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||
import { activityRouter } from './api/activity/router';
|
||||
@@ -30,6 +31,7 @@ import './api/music/sidecar-server'; // side-effect: capture the officer-music a
|
||||
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
@@ -113,6 +115,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/music', musicRouter);
|
||||
protectedRouter.route('/slskd', slskdRouter);
|
||||
protectedRouter.route('/headscale', headscaleRouter);
|
||||
protectedRouter.route('/transmission', transmissionRouter);
|
||||
protectedRouter.route('/vpn', vpnRouter);
|
||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||
protectedRouter.route('/activity', activityRouter);
|
||||
|
||||
@@ -70,6 +70,8 @@ export type SidecarEvent =
|
||||
| { type: 'slskd:server'; port: number }
|
||||
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'headscale:server'; port: number }
|
||||
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'transmission:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { probe } from './rpc';
|
||||
import { getTransmissionBase } from './upstream';
|
||||
|
||||
// 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 /_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
|
||||
// POST /_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": "<error>"}` 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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** 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);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
if (!getTransmissionBase()) {
|
||||
return Response.json({ ok: false, error: 'TRANSMISSION_URL not configured' }, { status: 503 });
|
||||
}
|
||||
const started = Date.now();
|
||||
const result = await probe();
|
||||
return Response.json({ ...result, ms: Date.now() - started }, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(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 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[transmission] listening on 127.0.0.1:${port} -> ${getTransmissionBase() ?? '(TRANSMISSION_URL unset)'}`);
|
||||
|
||||
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<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'transmission',
|
||||
capabilities: ['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'));
|
||||
@@ -0,0 +1,370 @@
|
||||
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<Record<string, unknown> | null> {
|
||||
const body = await req.json().catch(() => null);
|
||||
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */
|
||||
function readIds(body: Record<string, unknown>): 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<string, string> = {
|
||||
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<string, unknown>, allowed: Set<string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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.
|
||||
*
|
||||
* The platform injects X-Officer-User 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.
|
||||
*/
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
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<Response | null> {
|
||||
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<SessionSettings>('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('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<SessionSettings>('session-get');
|
||||
return Response.json({ session });
|
||||
}
|
||||
|
||||
return methodNotAllowed();
|
||||
}
|
||||
|
||||
async function handleStats(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||
const stats = await rpc<SessionStats>('session-stats');
|
||||
return Response.json({ stats });
|
||||
}
|
||||
|
||||
async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
|
||||
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 }>('free-space', { path });
|
||||
return Response.json({ path: result.path, bytes: result['size-bytes'] });
|
||||
}
|
||||
|
||||
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
const result = await rpc<{ 'port-is-open': boolean }>('port-test');
|
||||
return Response.json({ open: result['port-is-open'] });
|
||||
}
|
||||
|
||||
async function handleBlocklistUpdate(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
const result = await rpc<{ 'blocklist-size': number }>('blocklist-update');
|
||||
return Response.json({ size: result['blocklist-size'] });
|
||||
}
|
||||
|
||||
async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
|
||||
const { req } = ctx;
|
||||
|
||||
// GET /_officer/torrents — the list poll.
|
||||
if (rest.length === 0 && req.method === 'GET') {
|
||||
const result = await rpc<{ torrents: Torrent[] }>('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(req);
|
||||
case 'action':
|
||||
return await runAction(req);
|
||||
case 'set':
|
||||
return await setTorrent(req);
|
||||
case 'location':
|
||||
return await setLocation(req);
|
||||
case 'rename':
|
||||
return await renamePath(req);
|
||||
case 'remove':
|
||||
return await removeTorrents(req);
|
||||
}
|
||||
}
|
||||
|
||||
// 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[] }>('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(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const args: Record<string, unknown> = {};
|
||||
// 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 };
|
||||
}>('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(req: Request): Promise<Response> {
|
||||
const body = await readJson(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(method, { ids });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function setTorrent(req: Request): Promise<Response> {
|
||||
const body = await readJson(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('torrent-set', { ids, ...fields });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function setLocation(req: Request): Promise<Response> {
|
||||
const body = await readJson(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('torrent-set-location', { ids, location, move });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function renamePath(req: Request): Promise<Response> {
|
||||
const body = await readJson(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('torrent-rename-path', { ids: [id], path, name });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
async function removeTorrents(req: Request): Promise<Response> {
|
||||
const body = await readJson(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('torrent-remove', { ids, 'delete-local-data': deleteLocalData });
|
||||
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Torrent, TrackerStat } from './types';
|
||||
import { getAuthHeader, getRpcPath, getTransmissionBase } 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 getAuthHeader.
|
||||
// • `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 the current daemon session. Module-level because it is a property of the connection,
|
||||
// not of any one request, and every caller benefits from a refresh any one of them performs.
|
||||
let sessionId: string | null = null;
|
||||
|
||||
type RpcResponse<T> = { result: string; arguments?: T };
|
||||
|
||||
/**
|
||||
* Issue one RPC call. Retries exactly once on 409 after adopting the new session id; a second 409 means
|
||||
* something other than a stale token (a proxy stripping the header, most likely) and is surfaced.
|
||||
*/
|
||||
export async function rpc<T = unknown>(method: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return call<T>(method, args, true);
|
||||
}
|
||||
|
||||
async function call<T>(method: string, args: Record<string, unknown>, mayRetry: boolean): Promise<T> {
|
||||
const base = getTransmissionBase();
|
||||
if (!base) throw new TransmissionError(503, 'TRANSMISSION_URL is not configured');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
|
||||
const auth = getAuthHeader();
|
||||
if (auth) headers['Authorization'] = auth;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${base}${getRpcPath()}`, {
|
||||
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) {
|
||||
sessionId = fresh;
|
||||
return call<T>(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<T> | 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. */
|
||||
export async function probe(): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
||||
try {
|
||||
const args = await rpc<{ version: string; 'rpc-version': number }>('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<number, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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<string, unknown> & {
|
||||
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;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Transmission upstream config for the officer-transmission sidecar.
|
||||
//
|
||||
// All knowledge of the Transmission daemon (its URL, its RPC path and its credentials) lives here,
|
||||
// mirroring the officer-slskd/officer-vault philosophy: the platform API is a thin auth+forward proxy and
|
||||
// holds NO Transmission credentials.
|
||||
|
||||
const { TRANSMISSION_URL, TRANSMISSION_USER, TRANSMISSION_PASS, TRANSMISSION_RPC_PATH } = process.env;
|
||||
|
||||
let warnedUnset = false;
|
||||
|
||||
/** The Transmission base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
|
||||
export function getTransmissionBase(): string | null {
|
||||
const raw = TRANSMISSION_URL?.trim();
|
||||
if (!raw) {
|
||||
if (!warnedUnset) {
|
||||
console.warn('[transmission] TRANSMISSION_URL is unset — the sidecar will respond 503 until it is set');
|
||||
warnedUnset = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can
|
||||
* mount it anywhere, so it is configurable rather than hardcoded.
|
||||
*/
|
||||
export function getRpcPath(): string {
|
||||
const raw = TRANSMISSION_RPC_PATH?.trim();
|
||||
if (!raw) return '/transmission/rpc';
|
||||
return raw.startsWith('/') ? raw : `/${raw}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function getAuthHeader(): string | null {
|
||||
const user = TRANSMISSION_USER?.trim();
|
||||
if (!user) return null;
|
||||
const pass = TRANSMISSION_PASS ?? '';
|
||||
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user