@torrent-tv/proxy 2.56.0 → 2.57.1

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