Files
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

166 lines
7.1 KiB
TypeScript

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<string, string>();
type RpcResponse<T> = { result: string; arguments?: T };
/** Issue one RPC call against the owner's configured daemon. */
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
const cfg = await getTransmissionConfig(userId);
if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
return call<T>(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<T = unknown>(
cfg: TransmissionConfig,
method: string,
args: Record<string, unknown> = {},
): Promise<T> {
return call<T>(cfg, method, args, true);
}
async function call<T>(
cfg: TransmissionConfig,
method: string,
args: Record<string, unknown>,
mayRetry: boolean,
): Promise<T> {
const endpoint = `${cfg.base}${cfg.rpcPath}`;
const headers: Record<string, string> = { '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<T>(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<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 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<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;
}