@torrent-tv/proxy 2.61.0 → 2.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1,512 +1,520 @@
1
- #!/usr/bin/env node
2
- /**
3
- * @file CLI entry point for the torrent-tv proxy.
4
- *
5
- * Parses command-line arguments, starts the local HTTP server, opens a
6
- * persistent WebSocket tunnel to the registry server, and registers this
7
- * proxy. Liveness is tracked via the tunnel connection — the proxy re-registers
8
- * automatically on reconnect so the server's in-memory store stays consistent.
9
- */
10
-
11
- // FIRST, and it must stay first: it sets how many blocking calls this process
12
- // can have in flight, and a module's imports are evaluated before its body, so
13
- // anything imported above it would get the default pool. See the file itself
14
- // for the measurement that made it necessary.
15
- import "../services/thread-pool.js";
16
- import { Command } from "commander";
17
- import crypto from "node:crypto";
18
- import { spawnSync } from "node:child_process";
19
- import { createRequire } from "node:module";
20
- import ffmpegStatic from "ffmpeg-static";
21
- import { startProxyServer } from "../server.js";
22
- import { registerClient } from "../services/registry-api.js";
23
- import { createTunnelClient } from "../services/tunnel-client.js";
24
- import { createWebRtcManager } from "../services/webrtc-manager.js";
25
- import { createDataChannelHandler } from "../services/data-channel-handler.js";
26
- import { pruneCoreDumps } from "../services/core-dumps.js";
27
- import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
28
- import { createUsrsctpStateReader } from "../services/usrsctp-state.js";
29
- import { startMemoryReport } from "../services/memory-report.js";
30
- import { collectHealthMetrics } from "../services/health-collector.js";
31
- import { createPortMapper } from "../services/port-mapper.js";
32
- import { classifyNat } from "../services/nat-classifier.js";
33
- import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/segment-formats/index.js";
34
- import { logToFile, logger } from "../utils/logger.js";
35
-
36
- const require = createRequire(import.meta.url);
37
- const { version: PROXY_VERSION } = require("../package.json");
38
-
39
- // Last-resort process guard. This proxy runs UNTRUSTED torrents through
40
- // WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
41
- // v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
42
- // `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
43
- // the client "error" event. Without this, one bad torrent takes down the whole
44
- // node and every viewer on it, and the addon restarts in a crash loop. Log the
45
- // full stack and keep serving: the offending session fails on its own; everyone
46
- // else is unaffected. (The add path also pre-validates the infohash, so this is
47
- // a backstop for anything not caught there.)
48
- process.on("uncaughtException", (error) => {
49
- logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
50
- });
51
- process.on("unhandledRejection", (reason) => {
52
- logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
53
- });
54
-
55
- const program = new Command();
56
-
57
- const HELP_EXAMPLES = `
58
- Examples:
59
- torrent-tv-proxy --server-url http://localhost:8080
60
- torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
61
- torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
62
- torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
63
- torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
64
-
65
- Notes:
66
- - --help prints this message and exits with code 0.
67
- - Video transcode is available automatically for per-session fallback when requested by client API.
68
- `;
69
-
70
- if (process.argv.includes("help")) {
71
- process.argv = [process.argv[0], process.argv[1], "--help"];
72
- }
73
-
74
- program
75
- .name("torrent-proxy-client")
76
- .description("Expose torrent files over HTTP stream endpoints for browser playback.")
77
- .requiredOption("--server-url <url>", "Registry server base URL")
78
- .option("--public-base-url <url>", "Direct URL advertised to browser clients")
79
- .option("--host <host>", "Local bind host", "127.0.0.1")
80
- .option("--port <port>", "Local HTTP port", "9090")
81
- .option("--id <id>", "Stable client id")
82
- .option("--name <name>", "Display name")
83
- .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
84
- .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
85
- .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
86
- .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
87
- .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
88
- .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
89
- .option(
90
- "--state-dir <path>",
91
- "Where to keep what this host has measured about itself (default: beside the installed proxy)"
92
- )
93
- .option(
94
- "--log-file <path>",
95
- "Also write the log to this file, so a crash or an update does not take it with them"
96
- )
97
- .option(
98
- "--segment-format <format>",
99
- `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
100
- DEFAULT_SEGMENT_FORMAT_ID
101
- )
102
- .option("--token <token>", "Registration token", "")
103
- .addHelpText("after", HELP_EXAMPLES);
104
-
105
- program.parse(process.argv);
106
- const options = program.opts();
107
-
108
- const localPort = Number(options.port);
109
- if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
110
- logger.error("Invalid --port value.");
111
- process.exit(1);
112
- }
113
-
114
- const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
115
- const bindHost = String(options.host);
116
- const explicitBaseUrl = options.publicBaseUrl
117
- ? String(options.publicBaseUrl).replace(/\/+$/, "")
118
- : "";
119
- const clientId = options.id ? String(options.id) : crypto.randomUUID();
120
- const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
121
- const token = String(options.token ?? "");
122
- const transcodeAudio = options.transcodeAudio !== false;
123
- const portMappingEnabled = options.portMapping !== false;
124
- // Optional disk cap. undefined → the pool computes its own default; a valid
125
- // non-negative number (0 disables)passed through.
126
- const maxDiskBytes =
127
- options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
128
- ? Number(options.maxDiskBytes)
129
- : undefined;
130
- // Per-torrent memory budget for resident pieces. Pieces past it spill to disk
131
- // rather than being lost, so a small value costs read latency, never data.
132
- const memoryBytes =
133
- options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
134
- ? Number(options.memoryBytes)
135
- : undefined;
136
- const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
137
- const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
138
-
139
- /**
140
- * Verify that the ffmpeg binary is reachable and exits cleanly.
141
- * Throws with a descriptive message when the check fails.
142
- *
143
- * @returns {void}
144
- */
145
- function assertFfmpegAvailability() {
146
- const probe = spawnSync(ffmpegBin, ["-version"], {
147
- stdio: "ignore",
148
- windowsHide: true,
149
- timeout: 5000
150
- });
151
- if (probe.error) {
152
- const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
153
- throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
154
- }
155
- if (typeof probe.status === "number" && probe.status !== 0) {
156
- throw new Error(
157
- `Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
158
- );
159
- }
160
- }
161
-
162
- /** @type {boolean} */
163
- let registrationInProgress = false;
164
-
165
- /** @type {import("fastify").FastifyInstance | null} */
166
- let app = null;
167
-
168
- /** @type {number} */
169
- let actualPort = localPort;
170
-
171
- /** @type {boolean} */
172
- let shutdownInProgress = false;
173
-
174
- /** @type {ReturnType<typeof createTunnelClient> | null} */
175
- let tunnelClient = null;
176
-
177
- /** @type {ReturnType<typeof createPortMapper> | null} */
178
- let portMapper = null;
179
-
180
- /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
181
- let udpPortMapper = null;
182
-
183
- /** @type {ReturnType<typeof createWebRtcManager> | null} */
184
- let webRtcManager = null;
185
- /**
186
- * The packet witness, so shutdown can stop the ring it owns.
187
- *
188
- * @type {ReturnType<typeof createPacketWitness> | null}
189
- */
190
- let packetWitness = null;
191
-
192
- /** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
193
- let usrsctpStateReader = null;
194
-
195
- /** @type {ReturnType<typeof createDataChannelHandler> | null} */
196
- let dataChannelHandler = null;
197
-
198
- /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
199
- let natInfo = null;
200
-
201
-
202
- /**
203
- * Register this proxy with the registry server.
204
- * Silently skips if a registration is already in flight.
205
- *
206
- * @returns {Promise<void>}
207
- */
208
- async function registerClientSafe() {
209
- if (registrationInProgress) {
210
- return;
211
- }
212
- registrationInProgress = true;
213
- try {
214
- const result = await registerClient({
215
- serverUrl,
216
- id: clientId,
217
- name: clientName,
218
- baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
219
- token
220
- });
221
- logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
222
- } finally {
223
- registrationInProgress = false;
224
- }
225
- }
226
-
227
- /**
228
- * Register this proxy with the registry server, retrying indefinitely
229
- * with exponential backoff until it succeeds. This allows the proxy to
230
- * survive temporary server outages (restarts, deployments) without crashing.
231
- *
232
- * @returns {Promise<void>}
233
- */
234
- async function registerWithRetry() {
235
- const MAX_DELAY_MS = 60_000;
236
- let delayMs = 2_000;
237
- let attempt = 0;
238
- while (true) {
239
- attempt++;
240
- try {
241
- await registerClientSafe();
242
- return;
243
- } catch (error) {
244
- const message = error instanceof Error ? error.message : String(error);
245
- logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
246
- await new Promise((resolve) => setTimeout(resolve, delayMs));
247
- delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
248
- }
249
- }
250
- }
251
-
252
- /**
253
- * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
254
- *
255
- * @param {string} signal - Signal name (e.g. "SIGINT").
256
- * @returns {Promise<void>}
257
- */
258
- async function shutdown(signal) {
259
- if (shutdownInProgress) {
260
- return;
261
- }
262
- shutdownInProgress = true;
263
- if (tunnelClient) {
264
- tunnelClient.disconnect();
265
- tunnelClient = null;
266
- }
267
- logger.warn(`Received ${signal}, shutting down...`);
268
- try {
269
- // Remove the router port mappings before exiting (lease expiry is the
270
- // backstop if this is skipped on a hard kill).
271
- if (portMapper) {
272
- await portMapper.stop();
273
- portMapper = null;
274
- }
275
- if (udpPortMapper) {
276
- await udpPortMapper.stop();
277
- udpPortMapper = null;
278
- }
279
- // Close WebRTC sessions and release the shared UDP mux listener socket.
280
- if (webRtcManager) {
281
- try { webRtcManager.dispose(); } catch { /* ignore */ }
282
- webRtcManager = null;
283
- }
284
- // Stop the packet ring and remove its scratch files. `process.exit` below
285
- // does not take child processes with it, so without this a proxy restarted
286
- // outside a container leaves one tcpdump running per restart.
287
- if (packetWitness) {
288
- try { await packetWitness.dispose(); } catch { /* ignore */ }
289
- packetWitness = null;
290
- }
291
- if (app) {
292
- await app.close();
293
- }
294
- process.exit(0);
295
- } catch (error) {
296
- const message = error instanceof Error ? error.message : String(error);
297
- logger.error(`Shutdown failed: ${message}`);
298
- process.exit(1);
299
- }
300
- }
301
-
302
- try {
303
- logToFile(options.logFile);
304
- if (transcodeAudio) {
305
- assertFfmpegAvailability();
306
- }
307
- const started = await startProxyServer({
308
- host: bindHost,
309
- port: localPort,
310
- transcodeAudio,
311
- ffmpegBin,
312
- maxDiskBytes,
313
- memoryBytes,
314
- segmentFormat: options.segmentFormat,
315
- stateDir: options.stateDir,
316
- deliverySink: options.deliverySink === true,
317
- // Late-bound the same way `webRtcManager` is below: the torrent pool is
318
- // built inside `startProxyServer`, before `dataChannelHandler` the
319
- // thing that actually owns a channel to push down exists.
320
- onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
321
- });
322
- app = started.app;
323
- actualPort = started.port;
324
- const sourceRegistry = started.sourceRegistry;
325
- const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
326
-
327
- // A native fault writes the whole address space out — 4.18 GB each on the
328
- // field host, and four of them nearly filled a 235 GB disk. Keep the newest
329
- // two, which are the evidence for the fault still open, and drop the rest.
330
- void pruneCoreDumps(options.stateDir);
331
- // Same policy for the packet captures the send-queue witness writes.
332
- packetWitness = createPacketWitness({
333
- log: (message) => logger.info(message),
334
- dir: options.stateDir || "",
335
- port: actualPort
336
- });
337
- // A process that was KILLED mid-session leaves the ring's last seconds
338
- // behind, and those seconds contain whatever ended it. Keep them under a name
339
- // the pruner recognises BEFORE anything starts a new ring over them.
340
- void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
341
-
342
- // Reads usrsctp's own association state via gdb the moment a wedge is
343
- // declared (roadmap item 11) no source rebuild, the module ships
344
- // unstripped. A host without gdb just never gets a reading, the same way a
345
- // host without tcpdump never gets a packet capture.
346
- usrsctpStateReader = createUsrsctpStateReader({ log: (message) => logger.info(message) });
347
-
348
- // What this process holds, once a minute. The kernel killed the proxy on
349
- // 2026-08-28 at 2.4 GB resident and the log had never said a word about
350
- // memory, so the growth that ended in that kill has no shape in any record we
351
- // keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
352
- startMemoryReport({ log: (message) => logger.info(message) });
353
-
354
- logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
355
- logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
356
- logger.info(`Advertised direct URL: ${directBaseUrl}`);
357
- if (transcodeAudio) {
358
- logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
359
- }
360
-
361
- // All WebRTC sessions are multiplexed onto this single UDP port (same number
362
- // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
363
- // in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
364
- const webrtcUdpPort = actualPort;
365
-
366
- // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
367
- // is reachable from the internet without manual port forwarding. Best-effort
368
- // and fire-and-forget: failure is normal (router without UPnP) and must not
369
- // delay tunnel connect / registration, so we do not await it.
370
- if (portMappingEnabled) {
371
- portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
372
- void portMapper
373
- .start()
374
- .then(() => {
375
- // Mapping may finish after the tunnel is already connected; report the
376
- // endpoint now. If the tunnel is not open yet, onConnect re-sends it.
377
- const endpoint = portMapper?.getMappedEndpoint();
378
- if (endpoint) {
379
- tunnelClient?.sendEndpoint(endpoint);
380
- }
381
- })
382
- .catch((error) => {
383
- const message = error instanceof Error ? error.message : String(error);
384
- logger.warn(`Port mapping failed to start: ${message}`);
385
- });
386
-
387
- // Also map the single WebRTC UDP port. Not reported to the server: the
388
- // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
389
- // dial-back probe.
390
- udpPortMapper = createPortMapper({
391
- port: webrtcUdpPort,
392
- protocol: "UDP",
393
- description: "torrent-tv proxy (WebRTC)"
394
- });
395
- void udpPortMapper.start().catch((error) => {
396
- const message = error instanceof Error ? error.message : String(error);
397
- logger.warn(`UDP port mapping failed to start: ${message}`);
398
- });
399
- } else {
400
- logger.info("Automatic port mapping is disabled (--no-port-mapping).");
401
- }
402
-
403
- // Classify the home NAT (diagnostic + decides whether WebRTC will need port
404
- // prediction for remote viewers). Best-effort, fire-and-forget STUN probes
405
- // never block startup.
406
- void classifyNat()
407
- .then((nat) => {
408
- // Stored for WebRTC port prediction (webRtcManager reads it per session).
409
- natInfo = nat;
410
- if (nat.klass === "endpoint-independent") {
411
- logger.info(
412
- `nat: endpoint-independent (cone) external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
413
- );
414
- } else if (nat.klass === "symmetric") {
415
- logger.warn(
416
- `nat: SYMMETRIC external UDP port varies per destination (delta ${nat.portDelta}); WebRTC offers predicted ports (base+delta*k) — covers sequential/predictable symmetric NAT, not fully random`
417
- );
418
- } else {
419
- logger.info("nat: classification inconclusive (STUN probes failed); continuing");
420
- }
421
- })
422
- .catch((error) => {
423
- const message = error instanceof Error ? error.message : String(error);
424
- logger.warn(`nat classification failed: ${message}`);
425
- });
426
-
427
- // Create tunnel + WebRTC manager.
428
- // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
429
- // The WebRTC manager handles the actual peer connection and data channel.
430
- //
431
- // We use a late-binding ref so both objects can reference each other without
432
- // running into the TDZ (tunnelClient is declared above; webRtcManager is the
433
- // module-scoped `let` above so the closure in createTunnelClient — and the
434
- // shutdown handler — can reach it after initialisation).
435
-
436
- tunnelClient = createTunnelClient({
437
- serverUrl,
438
- proxyId: clientId,
439
- token,
440
- proxyPort: actualPort,
441
- onSignal(sessionId, signal) {
442
- webRtcManager?.handleSignal(sessionId, signal);
443
- },
444
- onHealthRequest() {
445
- return collectHealthMetrics();
446
- },
447
- onConnect() {
448
- // Re-register on every tunnel connect/reconnect so the server's
449
- // in-memory store stays consistent after server restarts.
450
- void registerClientSafe().catch((error) => {
451
- const message = error instanceof Error ? error.message : String(error);
452
- logger.error(`Re-registration after tunnel connect failed: ${message}`);
453
- });
454
- // Re-report the mapped endpoint on every (re)connect — the server's
455
- // in-memory reachability state resets on restart, and the mapping may
456
- // have completed before this connection existed.
457
- const endpoint = portMapper?.getMappedEndpoint();
458
- if (endpoint) {
459
- tunnelClient?.sendEndpoint(endpoint);
460
- }
461
- },
462
- onLog: (message) => logger.info(message)
463
- });
464
-
465
- dataChannelHandler = createDataChannelHandler({
466
- proxyPort: actualPort,
467
- onLog: (message) => logger.info(message),
468
- // Resolves a browser's registry sourceKey to the torrent pool's own key
469
- // (the content's infohash) so the subtitle push subscription and the
470
- // pool's own publish agree on what a source is called. See server.js.
471
- sourceRegistry,
472
- // Lets a stuck send queue ask the transport what it is doing. Late-bound:
473
- // the manager is created below, with this handler already in hand.
474
- getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
475
- // Records the wire when a queue stays wedged — how the rare one-way
476
- // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
477
- witness: packetWitness,
478
- usrsctpState: usrsctpStateReader
479
- });
480
-
481
- webRtcManager = createWebRtcManager({
482
- // Single UDP port (UPnP-mapped above) shared by all sessions via a
483
- // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
484
- // internet on one fixed port.
485
- udpPort: webrtcUdpPort,
486
- // Latest NAT classification — enables symmetric-NAT port prediction.
487
- getNatInfo: () => natInfo,
488
- sendSignal(sessionId, signal) {
489
- tunnelClient?.sendSignal(sessionId, signal);
490
- },
491
- onDataChannel(sessionId, channel) {
492
- dataChannelHandler.handleChannel(sessionId, channel);
493
- },
494
- onLog: (message) => logger.info(message)
495
- });
496
-
497
- tunnelClient.connect();
498
-
499
- await registerWithRetry();
500
- } catch (error) {
501
- const message = error instanceof Error ? error.message : String(error);
502
- logger.error(message);
503
- process.exit(1);
504
- }
505
-
506
- process.on("SIGINT", () => {
507
- void shutdown("SIGINT");
508
- });
509
-
510
- process.on("SIGTERM", () => {
511
- void shutdown("SIGTERM");
512
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file CLI entry point for the torrent-tv proxy.
4
+ *
5
+ * Parses command-line arguments, starts the local HTTP server, opens a
6
+ * persistent WebSocket tunnel to the registry server, and registers this
7
+ * proxy. Liveness is tracked via the tunnel connection — the proxy re-registers
8
+ * automatically on reconnect so the server's in-memory store stays consistent.
9
+ */
10
+
11
+ // FIRST, and it must stay first: it sets how many blocking calls this process
12
+ // can have in flight, and a module's imports are evaluated before its body, so
13
+ // anything imported above it would get the default pool. See the file itself
14
+ // for the measurement that made it necessary.
15
+ import "../services/thread-pool.js";
16
+ import { Command } from "commander";
17
+ import crypto from "node:crypto";
18
+ import os from "node:os";
19
+ import { spawnSync } from "node:child_process";
20
+ import { createRequire } from "node:module";
21
+ import ffmpegStatic from "ffmpeg-static";
22
+ import { startProxyServer } from "../server.js";
23
+ import { registerClient } from "../services/registry-api.js";
24
+ import { createTunnelClient } from "../services/tunnel-client.js";
25
+ import { createWebRtcManager } from "../services/webrtc-manager.js";
26
+ import { createDataChannelHandler } from "../services/data-channel-handler.js";
27
+ import { pruneCoreDumps } from "../services/core-dumps.js";
28
+ import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
29
+ import { createUsrsctpStateReader } from "../services/usrsctp-state.js";
30
+ import { startMemoryReport } from "../services/memory-report.js";
31
+ import { collectHealthMetrics } from "../services/health-collector.js";
32
+ import { createPortMapper } from "../services/port-mapper.js";
33
+ import { classifyNat } from "../services/nat-classifier.js";
34
+ import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/segment-formats/index.js";
35
+ import { logToFile, logger } from "../utils/logger.js";
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const { version: PROXY_VERSION } = require("../package.json");
39
+
40
+ // Last-resort process guard. This proxy runs UNTRUSTED torrents through
41
+ // WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
42
+ // v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
43
+ // `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
44
+ // the client "error" event. Without this, one bad torrent takes down the whole
45
+ // node and every viewer on it, and the addon restarts in a crash loop. Log the
46
+ // full stack and keep serving: the offending session fails on its own; everyone
47
+ // else is unaffected. (The add path also pre-validates the infohash, so this is
48
+ // a backstop for anything not caught there.)
49
+ process.on("uncaughtException", (error) => {
50
+ logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
51
+ });
52
+ process.on("unhandledRejection", (reason) => {
53
+ logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
54
+ });
55
+
56
+ const program = new Command();
57
+
58
+ const HELP_EXAMPLES = `
59
+ Examples:
60
+ torrent-tv-proxy --server-url http://localhost:8080
61
+ torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
62
+ torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
63
+ torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
64
+ torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
65
+
66
+ Notes:
67
+ - --help prints this message and exits with code 0.
68
+ - Video transcode is available automatically for per-session fallback when requested by client API.
69
+ `;
70
+
71
+ if (process.argv.includes("help")) {
72
+ process.argv = [process.argv[0], process.argv[1], "--help"];
73
+ }
74
+
75
+ program
76
+ .name("torrent-proxy-client")
77
+ .description("Expose torrent files over HTTP stream endpoints for browser playback.")
78
+ .requiredOption("--server-url <url>", "Registry server base URL")
79
+ .option("--public-base-url <url>", "Direct URL advertised to browser clients")
80
+ .option("--host <host>", "Local bind host", "127.0.0.1")
81
+ .option("--port <port>", "Local HTTP port", "9090")
82
+ .option("--id <id>", "Stable client id")
83
+ .option("--name <name>", "Display name")
84
+ .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
85
+ .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
86
+ .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
87
+ .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
88
+ .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
89
+ .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
90
+ .option(
91
+ "--state-dir <path>",
92
+ "Where to keep what this host has measured about itself (default: beside the installed proxy)"
93
+ )
94
+ .option(
95
+ "--log-file <path>",
96
+ "Also write the log to this file, so a crash or an update does not take it with them"
97
+ )
98
+ .option(
99
+ "--segment-format <format>",
100
+ `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
101
+ DEFAULT_SEGMENT_FORMAT_ID
102
+ )
103
+ .option("--token <token>", "Registration token", "")
104
+ .addHelpText("after", HELP_EXAMPLES);
105
+
106
+ program.parse(process.argv);
107
+ const options = program.opts();
108
+
109
+ const localPort = Number(options.port);
110
+ if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
111
+ logger.error("Invalid --port value.");
112
+ process.exit(1);
113
+ }
114
+
115
+ const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
116
+ const bindHost = String(options.host);
117
+ const explicitBaseUrl = options.publicBaseUrl
118
+ ? String(options.publicBaseUrl).replace(/\/+$/, "")
119
+ : "";
120
+ const clientId = options.id ? String(options.id) : crypto.randomUUID();
121
+ const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
122
+ const token = String(options.token ?? "");
123
+ const transcodeAudio = options.transcodeAudio !== false;
124
+ const portMappingEnabled = options.portMapping !== false;
125
+ // Optional disk cap. undefinedthe pool computes its own default; a valid
126
+ // non-negative number (0 disables) → passed through.
127
+ const maxDiskBytes =
128
+ options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
129
+ ? Number(options.maxDiskBytes)
130
+ : undefined;
131
+ // Per-torrent memory budget for resident pieces. Pieces past it spill to disk
132
+ // rather than being lost, so a small value costs read latency, never data.
133
+ const memoryBytes =
134
+ options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
135
+ ? Number(options.memoryBytes)
136
+ : undefined;
137
+ const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
138
+ const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
139
+
140
+ /**
141
+ * Verify that the ffmpeg binary is reachable and exits cleanly.
142
+ * Throws with a descriptive message when the check fails.
143
+ *
144
+ * @returns {void}
145
+ */
146
+ function assertFfmpegAvailability() {
147
+ const probe = spawnSync(ffmpegBin, ["-version"], {
148
+ stdio: "ignore",
149
+ windowsHide: true,
150
+ timeout: 5000
151
+ });
152
+ if (probe.error) {
153
+ const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
154
+ throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
155
+ }
156
+ if (typeof probe.status === "number" && probe.status !== 0) {
157
+ throw new Error(
158
+ `Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
159
+ );
160
+ }
161
+ }
162
+
163
+ /** @type {boolean} */
164
+ let registrationInProgress = false;
165
+
166
+ /** @type {import("fastify").FastifyInstance | null} */
167
+ let app = null;
168
+
169
+ /** @type {number} */
170
+ let actualPort = localPort;
171
+
172
+ /** @type {boolean} */
173
+ let shutdownInProgress = false;
174
+
175
+ /** @type {ReturnType<typeof createTunnelClient> | null} */
176
+ let tunnelClient = null;
177
+
178
+ /** @type {ReturnType<typeof createPortMapper> | null} */
179
+ let portMapper = null;
180
+
181
+ /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
182
+ let udpPortMapper = null;
183
+
184
+ /** @type {ReturnType<typeof createWebRtcManager> | null} */
185
+ let webRtcManager = null;
186
+ /**
187
+ * The packet witness, so shutdown can stop the ring it owns.
188
+ *
189
+ * @type {ReturnType<typeof createPacketWitness> | null}
190
+ */
191
+ let packetWitness = null;
192
+
193
+ /** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
194
+ let usrsctpStateReader = null;
195
+
196
+ /** @type {ReturnType<typeof createDataChannelHandler> | null} */
197
+ let dataChannelHandler = null;
198
+
199
+ /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
200
+ let natInfo = null;
201
+
202
+
203
+ /**
204
+ * Register this proxy with the registry server.
205
+ * Silently skips if a registration is already in flight.
206
+ *
207
+ * @returns {Promise<void>}
208
+ */
209
+ async function registerClientSafe() {
210
+ if (registrationInProgress) {
211
+ return;
212
+ }
213
+ registrationInProgress = true;
214
+ try {
215
+ const result = await registerClient({
216
+ serverUrl,
217
+ id: clientId,
218
+ name: clientName,
219
+ baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
220
+ token
221
+ });
222
+ logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
223
+ } finally {
224
+ registrationInProgress = false;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Register this proxy with the registry server, retrying indefinitely
230
+ * with exponential backoff until it succeeds. This allows the proxy to
231
+ * survive temporary server outages (restarts, deployments) without crashing.
232
+ *
233
+ * @returns {Promise<void>}
234
+ */
235
+ async function registerWithRetry() {
236
+ const MAX_DELAY_MS = 60_000;
237
+ let delayMs = 2_000;
238
+ let attempt = 0;
239
+ while (true) {
240
+ attempt++;
241
+ try {
242
+ await registerClientSafe();
243
+ return;
244
+ } catch (error) {
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
247
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
248
+ delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
255
+ *
256
+ * @param {string} signal - Signal name (e.g. "SIGINT").
257
+ * @returns {Promise<void>}
258
+ */
259
+ async function shutdown(signal) {
260
+ if (shutdownInProgress) {
261
+ return;
262
+ }
263
+ shutdownInProgress = true;
264
+ if (tunnelClient) {
265
+ tunnelClient.disconnect();
266
+ tunnelClient = null;
267
+ }
268
+ logger.warn(`Received ${signal}, shutting down...`);
269
+ try {
270
+ // Remove the router port mappings before exiting (lease expiry is the
271
+ // backstop if this is skipped on a hard kill).
272
+ if (portMapper) {
273
+ await portMapper.stop();
274
+ portMapper = null;
275
+ }
276
+ if (udpPortMapper) {
277
+ await udpPortMapper.stop();
278
+ udpPortMapper = null;
279
+ }
280
+ // Close WebRTC sessions and release the shared UDP mux listener socket.
281
+ if (webRtcManager) {
282
+ try { webRtcManager.dispose(); } catch { /* ignore */ }
283
+ webRtcManager = null;
284
+ }
285
+ // Stop the packet ring and remove its scratch files. `process.exit` below
286
+ // does not take child processes with it, so without this a proxy restarted
287
+ // outside a container leaves one tcpdump running per restart.
288
+ if (packetWitness) {
289
+ try { await packetWitness.dispose(); } catch { /* ignore */ }
290
+ packetWitness = null;
291
+ }
292
+ if (app) {
293
+ await app.close();
294
+ }
295
+ process.exit(0);
296
+ } catch (error) {
297
+ const message = error instanceof Error ? error.message : String(error);
298
+ logger.error(`Shutdown failed: ${message}`);
299
+ process.exit(1);
300
+ }
301
+ }
302
+
303
+ try {
304
+ logToFile(options.logFile);
305
+ if (transcodeAudio) {
306
+ assertFfmpegAvailability();
307
+ }
308
+ const started = await startProxyServer({
309
+ host: bindHost,
310
+ port: localPort,
311
+ transcodeAudio,
312
+ ffmpegBin,
313
+ maxDiskBytes,
314
+ memoryBytes,
315
+ segmentFormat: options.segmentFormat,
316
+ stateDir: options.stateDir,
317
+ deliverySink: options.deliverySink === true,
318
+ // Late-bound the same way `webRtcManager` is below: the torrent pool is
319
+ // built inside `startProxyServer`, before `dataChannelHandler`the
320
+ // thing that actually owns a channel to push down — exists.
321
+ onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
322
+ });
323
+ app = started.app;
324
+ actualPort = started.port;
325
+ const sourceRegistry = started.sourceRegistry;
326
+ const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
327
+
328
+ // A native fault writes the whole address space out 4.18 GB each on the
329
+ // field host, and four of them nearly filled a 235 GB disk. Keep the newest
330
+ // two, which are the evidence for the fault still open, and drop the rest.
331
+ void pruneCoreDumps(options.stateDir);
332
+ // Same policy for the packet captures the send-queue witness writes.
333
+ packetWitness = createPacketWitness({
334
+ log: (message) => logger.info(message),
335
+ dir: options.stateDir || "",
336
+ port: actualPort
337
+ });
338
+ // A process that was KILLED mid-session leaves the ring's last seconds
339
+ // behind, and those seconds contain whatever ended it. Keep them under a name
340
+ // the pruner recognises BEFORE anything starts a new ring over them.
341
+ void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
342
+
343
+ // Reads usrsctp's own association state via gdb the moment a wedge is
344
+ // declared (roadmap item 11) no source rebuild, the module ships
345
+ // unstripped. A host without gdb just never gets a reading, the same way a
346
+ // host without tcpdump never gets a packet capture.
347
+ usrsctpStateReader = createUsrsctpStateReader({ log: (message) => logger.info(message) });
348
+
349
+ // What this process holds, once a minute. The kernel killed the proxy on
350
+ // 2026-08-28 at 2.4 GB resident and the log had never said a word about
351
+ // memory, so the growth that ended in that kill has no shape in any record we
352
+ // keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
353
+ // The disk path is where pieces spill and torrent data lands. A budget for
354
+ // memory alone is half a budget: a store that behaves about RAM can still
355
+ // fill the card an addon host boots from, and neither limit was measured
356
+ // before (roadmap item 2).
357
+ startMemoryReport({
358
+ log: (message) => logger.info(message),
359
+ diskPath: os.tmpdir()
360
+ });
361
+
362
+ logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
363
+ logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
364
+ logger.info(`Advertised direct URL: ${directBaseUrl}`);
365
+ if (transcodeAudio) {
366
+ logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
367
+ }
368
+
369
+ // All WebRTC sessions are multiplexed onto this single UDP port (same number
370
+ // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
371
+ // in the WebRTC manager. One port one UPnP mapping → one reachable endpoint.
372
+ const webrtcUdpPort = actualPort;
373
+
374
+ // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
375
+ // is reachable from the internet without manual port forwarding. Best-effort
376
+ // and fire-and-forget: failure is normal (router without UPnP) and must not
377
+ // delay tunnel connect / registration, so we do not await it.
378
+ if (portMappingEnabled) {
379
+ portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
380
+ void portMapper
381
+ .start()
382
+ .then(() => {
383
+ // Mapping may finish after the tunnel is already connected; report the
384
+ // endpoint now. If the tunnel is not open yet, onConnect re-sends it.
385
+ const endpoint = portMapper?.getMappedEndpoint();
386
+ if (endpoint) {
387
+ tunnelClient?.sendEndpoint(endpoint);
388
+ }
389
+ })
390
+ .catch((error) => {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ logger.warn(`Port mapping failed to start: ${message}`);
393
+ });
394
+
395
+ // Also map the single WebRTC UDP port. Not reported to the server: the
396
+ // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
397
+ // dial-back probe.
398
+ udpPortMapper = createPortMapper({
399
+ port: webrtcUdpPort,
400
+ protocol: "UDP",
401
+ description: "torrent-tv proxy (WebRTC)"
402
+ });
403
+ void udpPortMapper.start().catch((error) => {
404
+ const message = error instanceof Error ? error.message : String(error);
405
+ logger.warn(`UDP port mapping failed to start: ${message}`);
406
+ });
407
+ } else {
408
+ logger.info("Automatic port mapping is disabled (--no-port-mapping).");
409
+ }
410
+
411
+ // Classify the home NAT (diagnostic + decides whether WebRTC will need port
412
+ // prediction for remote viewers). Best-effort, fire-and-forget STUN probes
413
+ // never block startup.
414
+ void classifyNat()
415
+ .then((nat) => {
416
+ // Stored for WebRTC port prediction (webRtcManager reads it per session).
417
+ natInfo = nat;
418
+ if (nat.klass === "endpoint-independent") {
419
+ logger.info(
420
+ `nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
421
+ );
422
+ } else if (nat.klass === "symmetric") {
423
+ logger.warn(
424
+ `nat: SYMMETRIC external UDP port varies per destination (delta ${nat.portDelta}); WebRTC offers predicted ports (base+delta*k) — covers sequential/predictable symmetric NAT, not fully random`
425
+ );
426
+ } else {
427
+ logger.info("nat: classification inconclusive (STUN probes failed); continuing");
428
+ }
429
+ })
430
+ .catch((error) => {
431
+ const message = error instanceof Error ? error.message : String(error);
432
+ logger.warn(`nat classification failed: ${message}`);
433
+ });
434
+
435
+ // Create tunnel + WebRTC manager.
436
+ // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
437
+ // The WebRTC manager handles the actual peer connection and data channel.
438
+ //
439
+ // We use a late-binding ref so both objects can reference each other without
440
+ // running into the TDZ (tunnelClient is declared above; webRtcManager is the
441
+ // module-scoped `let` above so the closure in createTunnelClient — and the
442
+ // shutdown handler — can reach it after initialisation).
443
+
444
+ tunnelClient = createTunnelClient({
445
+ serverUrl,
446
+ proxyId: clientId,
447
+ token,
448
+ proxyPort: actualPort,
449
+ onSignal(sessionId, signal) {
450
+ webRtcManager?.handleSignal(sessionId, signal);
451
+ },
452
+ onHealthRequest() {
453
+ return collectHealthMetrics();
454
+ },
455
+ onConnect() {
456
+ // Re-register on every tunnel connect/reconnect so the server's
457
+ // in-memory store stays consistent after server restarts.
458
+ void registerClientSafe().catch((error) => {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ logger.error(`Re-registration after tunnel connect failed: ${message}`);
461
+ });
462
+ // Re-report the mapped endpoint on every (re)connect the server's
463
+ // in-memory reachability state resets on restart, and the mapping may
464
+ // have completed before this connection existed.
465
+ const endpoint = portMapper?.getMappedEndpoint();
466
+ if (endpoint) {
467
+ tunnelClient?.sendEndpoint(endpoint);
468
+ }
469
+ },
470
+ onLog: (message) => logger.info(message)
471
+ });
472
+
473
+ dataChannelHandler = createDataChannelHandler({
474
+ proxyPort: actualPort,
475
+ onLog: (message) => logger.info(message),
476
+ // Resolves a browser's registry sourceKey to the torrent pool's own key
477
+ // (the content's infohash) so the subtitle push subscription and the
478
+ // pool's own publish agree on what a source is called. See server.js.
479
+ sourceRegistry,
480
+ // Lets a stuck send queue ask the transport what it is doing. Late-bound:
481
+ // the manager is created below, with this handler already in hand.
482
+ getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
483
+ // Records the wire when a queue stays wedged how the rare one-way
484
+ // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
485
+ witness: packetWitness,
486
+ usrsctpState: usrsctpStateReader
487
+ });
488
+
489
+ webRtcManager = createWebRtcManager({
490
+ // Single UDP port (UPnP-mapped above) shared by all sessions via a
491
+ // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
492
+ // internet on one fixed port.
493
+ udpPort: webrtcUdpPort,
494
+ // Latest NAT classification — enables symmetric-NAT port prediction.
495
+ getNatInfo: () => natInfo,
496
+ sendSignal(sessionId, signal) {
497
+ tunnelClient?.sendSignal(sessionId, signal);
498
+ },
499
+ onDataChannel(sessionId, channel) {
500
+ dataChannelHandler.handleChannel(sessionId, channel);
501
+ },
502
+ onLog: (message) => logger.info(message)
503
+ });
504
+
505
+ tunnelClient.connect();
506
+
507
+ await registerWithRetry();
508
+ } catch (error) {
509
+ const message = error instanceof Error ? error.message : String(error);
510
+ logger.error(message);
511
+ process.exit(1);
512
+ }
513
+
514
+ process.on("SIGINT", () => {
515
+ void shutdown("SIGINT");
516
+ });
517
+
518
+ process.on("SIGTERM", () => {
519
+ void shutdown("SIGTERM");
520
+ });