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>
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user