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,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'));
|
||||
Reference in New Issue
Block a user