@torrent-tv/proxy 2.0.1 → 2.2.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,11 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import chalk from "chalk";
2
+ /**
3
+ * @file CLI entry point for the torrent-tv proxy.
4
+ *
5
+ * Parses command-line arguments, starts the local HTTP server, registers
6
+ * this proxy with the registry server, establishes the WebSocket tunnel,
7
+ * and maintains a periodic heartbeat.
8
+ */
9
+
3
10
  import { Command } from "commander";
4
11
  import crypto from "node:crypto";
5
12
  import { spawnSync } from "node:child_process";
6
13
  import ffmpegStatic from "ffmpeg-static";
7
14
  import { startProxyServer } from "../server.js";
8
15
  import { registerClient, sendHeartbeat } from "../services/registry-api.js";
16
+ import { createTunnelClient } from "../services/tunnel-client.js";
17
+ import { logger } from "../utils/logger.js";
9
18
 
10
19
  const program = new Command();
11
20
 
@@ -45,7 +54,7 @@ const options = program.opts();
45
54
 
46
55
  const localPort = Number(options.port);
47
56
  if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
48
- console.error(chalk.red("[proxy-client] Invalid --port value."));
57
+ logger.error("Invalid --port value.");
49
58
  process.exit(1);
50
59
  }
51
60
 
@@ -61,6 +70,12 @@ const transcodeAudio = options.transcodeAudio !== false;
61
70
  const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
62
71
  const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
63
72
 
73
+ /**
74
+ * Verify that the ffmpeg binary is reachable and exits cleanly.
75
+ * Throws with a descriptive message when the check fails.
76
+ *
77
+ * @returns {void}
78
+ */
64
79
  function assertFfmpegAvailability() {
65
80
  const probe = spawnSync(ffmpegBin, ["-version"], {
66
81
  stdio: "ignore",
@@ -78,12 +93,30 @@ function assertFfmpegAvailability() {
78
93
  }
79
94
  }
80
95
 
96
+ /** @type {boolean} */
81
97
  let registrationInProgress = false;
98
+
99
+ /** @type {ReturnType<typeof setInterval> | null} */
82
100
  let heartbeatTimer = null;
101
+
102
+ /** @type {import("fastify").FastifyInstance | null} */
83
103
  let app = null;
104
+
105
+ /** @type {number} */
84
106
  let actualPort = localPort;
107
+
108
+ /** @type {boolean} */
85
109
  let shutdownInProgress = false;
86
110
 
111
+ /** @type {ReturnType<typeof import("../services/tunnel-client.js").createTunnelClient> | null} */
112
+ let tunnelClient = null;
113
+
114
+ /**
115
+ * Register this proxy with the registry server.
116
+ * Silently skips if a registration is already in flight.
117
+ *
118
+ * @returns {Promise<void>}
119
+ */
87
120
  async function registerClientSafe() {
88
121
  if (registrationInProgress) {
89
122
  return;
@@ -97,12 +130,43 @@ async function registerClientSafe() {
97
130
  baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
98
131
  token
99
132
  });
100
- console.log(chalk.green(`[proxy-client] Registered: ${JSON.stringify(result.client)}`));
133
+ logger.success(`Registered: ${JSON.stringify(result.client)}`);
101
134
  } finally {
102
135
  registrationInProgress = false;
103
136
  }
104
137
  }
105
138
 
139
+ /**
140
+ * Register this proxy with the registry server, retrying indefinitely
141
+ * with exponential backoff until it succeeds. This allows the proxy to
142
+ * survive temporary server outages (restarts, deployments) without crashing.
143
+ *
144
+ * @returns {Promise<void>}
145
+ */
146
+ async function registerWithRetry() {
147
+ const MAX_DELAY_MS = 60_000;
148
+ let delayMs = 2_000;
149
+ let attempt = 0;
150
+ while (true) {
151
+ attempt++;
152
+ try {
153
+ await registerClientSafe();
154
+ return;
155
+ } catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
158
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
159
+ delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
166
+ *
167
+ * @param {string} signal - Signal name (e.g. "SIGINT").
168
+ * @returns {Promise<void>}
169
+ */
106
170
  async function shutdown(signal) {
107
171
  if (shutdownInProgress) {
108
172
  return;
@@ -112,7 +176,11 @@ async function shutdown(signal) {
112
176
  clearInterval(heartbeatTimer);
113
177
  heartbeatTimer = null;
114
178
  }
115
- console.log(chalk.yellow(`[proxy-client] Received ${signal}, shutting down...`));
179
+ if (tunnelClient) {
180
+ tunnelClient.disconnect();
181
+ tunnelClient = null;
182
+ }
183
+ logger.warn(`Received ${signal}, shutting down...`);
116
184
  try {
117
185
  if (app) {
118
186
  await app.close();
@@ -120,7 +188,7 @@ async function shutdown(signal) {
120
188
  process.exit(0);
121
189
  } catch (error) {
122
190
  const message = error instanceof Error ? error.message : String(error);
123
- console.error(chalk.red(`[proxy-client] Shutdown failed: ${message}`));
191
+ logger.error(`Shutdown failed: ${message}`);
124
192
  process.exit(1);
125
193
  }
126
194
  }
@@ -139,15 +207,22 @@ try {
139
207
  actualPort = started.port;
140
208
  const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
141
209
 
142
- console.log(chalk.cyan(`[proxy-client] Local stream endpoint: http://${bindHost}:${actualPort}/stream`));
143
- console.log(chalk.cyan(`[proxy-client] Advertised direct URL: ${directBaseUrl}`));
210
+ logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
211
+ logger.info(`Advertised direct URL: ${directBaseUrl}`);
144
212
  if (transcodeAudio) {
145
- console.log(
146
- chalk.cyan(`[proxy-client] Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`)
147
- );
213
+ logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
148
214
  }
149
215
 
150
- await registerClientSafe();
216
+ await registerWithRetry();
217
+
218
+ tunnelClient = createTunnelClient({
219
+ serverUrl,
220
+ proxyId: clientId,
221
+ token,
222
+ proxyPort: actualPort,
223
+ onLog: (msg) => logger.info(msg)
224
+ });
225
+ tunnelClient.connect();
151
226
 
152
227
  heartbeatTimer = setInterval(async () => {
153
228
  const status = await sendHeartbeat({
@@ -156,18 +231,18 @@ try {
156
231
  token
157
232
  });
158
233
  if (status === 404) {
159
- console.log(chalk.yellow("[proxy-client] Heartbeat returned 404, re-registering..."));
234
+ logger.warn("Heartbeat returned 404, re-registering...");
160
235
  try {
161
236
  await registerClientSafe();
162
237
  } catch (error) {
163
238
  const message = error instanceof Error ? error.message : String(error);
164
- console.error(chalk.red(`[proxy-client] Re-register failed: ${message}`));
239
+ logger.error(`Re-register failed: ${message}`);
165
240
  }
166
241
  }
167
242
  }, 20_000);
168
243
  } catch (error) {
169
244
  const message = error instanceof Error ? error.message : String(error);
170
- console.error(chalk.red(`[proxy-client] ${message}`));
245
+ logger.error(message);
171
246
  process.exit(1);
172
247
  }
173
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Determine the best playback mode (direct stream or HLS transcode) for a
3
+ * torrent file and return the corresponding plan.
4
+ *
5
+ * POST /api/playback-plan
6
+ *
7
+ * @param {import("fastify").FastifyRequest} req
8
+ * @param {import("fastify").FastifyReply} reply
9
+ * @param {{ playbackPlanner: ReturnType<import("../../../services/playback-planner.js").createPlaybackPlanner> }} deps
10
+ * @returns {Promise<void>}
11
+ */
12
+
13
+ /**
14
+ * Extract a plain object from the request body, guarding against
15
+ * non-object payloads (arrays, primitives, null).
16
+ *
17
+ * @param {unknown} body
18
+ * @returns {Record<string, unknown>}
19
+ */
1
20
  function getPayload(body) {
2
21
  if (body && typeof body === "object" && !Array.isArray(body)) {
3
22
  return body;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * Register a torrent source with the proxy and receive a stable source key.
3
+ *
4
+ * POST /api/sources
5
+ *
6
+ * @param {import("fastify").FastifyRequest} req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry> }} deps
9
+ * @returns {Promise<void>}
10
+ */
11
+
12
+ /**
13
+ * Extract a plain object from the request body, guarding against
14
+ * non-object payloads (arrays, primitives, null).
15
+ *
16
+ * @param {unknown} body
17
+ * @returns {Record<string, unknown>}
18
+ */
1
19
  function getPayload(body) {
2
20
  if (body && typeof body === "object" && !Array.isArray(body)) {
3
21
  return body;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * Create or return an existing HLS transcode session for a torrent file.
3
+ *
4
+ * POST /api/transcode-sessions
5
+ *
6
+ * @param {import("fastify").FastifyRequest} req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
9
+ * @returns {Promise<void>}
10
+ */
11
+
12
+ /**
13
+ * Extract a plain object from the request body, guarding against
14
+ * non-object payloads (arrays, primitives, null).
15
+ *
16
+ * @param {unknown} body
17
+ * @returns {Record<string, unknown>}
18
+ */
1
19
  function getPayload(body) {
2
20
  if (body && typeof body === "object" && !Array.isArray(body)) {
3
21
  return body;
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Return the current encoding progress for an active HLS transcode session.
3
+ *
4
+ * GET /api/transcode-sessions/:sessionId/progress
5
+ *
6
+ * @param {import("fastify").FastifyRequest} req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
9
+ * @returns {Promise<void>}
10
+ */
1
11
  export async function handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager }) {
2
12
  const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
3
13
  if (!sessionId) {
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Release a consumer from a transcode session.
3
+ * When the last consumer is released the session is disposed automatically.
4
+ *
5
+ * POST /api/transcode-sessions/:sessionId/release
6
+ *
7
+ * @param {import("fastify").FastifyRequest} req
8
+ * @param {import("fastify").FastifyReply} reply
9
+ * @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
10
+ * @returns {Promise<void>}
11
+ */
12
+
13
+ /**
14
+ * Extract a plain object from the request body, guarding against
15
+ * non-object payloads (arrays, primitives, null).
16
+ *
17
+ * @param {unknown} body
18
+ * @returns {Record<string, unknown>}
19
+ */
1
20
  function getPayload(body) {
2
21
  if (body && typeof body === "object" && !Array.isArray(body)) {
3
22
  return body;
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Basic liveness check for load balancers and container orchestrators.
3
+ *
4
+ * GET /health
5
+ *
6
+ * @param {import("fastify").FastifyRequest} _req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @returns {Promise<void>}
9
+ */
1
10
  export async function handleHealthGet(_req, reply) {
2
11
  return reply.send({ ok: true });
3
12
  }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Docker / Kubernetes readiness probe endpoint.
3
+ *
4
+ * GET /healthz
5
+ *
6
+ * @param {import("fastify").FastifyRequest} _req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @returns {Promise<void>}
9
+ */
1
10
  export async function handleHealthzGet(_req, reply) {
2
11
  return reply.send({ ok: true });
3
12
  }
@@ -1,5 +1,21 @@
1
+ /**
2
+ * @file Byte-range aware torrent file streaming endpoint.
3
+ *
4
+ * Accepts either a `sourceKey` (registered via POST /api/sources) or a raw
5
+ * `sourceType` + `source` pair. Responds with HTTP 206 for range requests
6
+ * and HTTP 200 for full-file requests.
7
+ */
8
+
1
9
  import { parseRange } from "../../utils/parse-range.js";
2
10
 
11
+ /**
12
+ * Resolve source parameters from the query string.
13
+ * Prefers a registered `sourceKey`; falls back to inline `sourceType`+`source`.
14
+ *
15
+ * @param {import("fastify").FastifyRequest["query"]} query
16
+ * @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
17
+ * @returns {{ sourceType: string, source: string }}
18
+ */
3
19
  function getSourceParams(query, sourceRegistry) {
4
20
  const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
5
21
  const sourceTypeFromQuery = typeof query.sourceType === "string" ? query.sourceType : "";
@@ -11,6 +27,16 @@ function getSourceParams(query, sourceRegistry) {
11
27
  return { sourceType, source };
12
28
  }
13
29
 
30
+ /**
31
+ * Stream a torrent file over HTTP with byte-range support.
32
+ *
33
+ * GET /stream
34
+ *
35
+ * @param {import("fastify").FastifyRequest} req
36
+ * @param {import("fastify").FastifyReply} reply
37
+ * @param {{ sourceRegistry: ReturnType<import("../../store/source-registry.js").createSourceRegistry>, torrentPool: import("../../services/torrent-pool.js").TorrentPool }} deps
38
+ * @returns {Promise<void>}
39
+ */
14
40
  export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool }) {
15
41
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
16
42
  const fileIndex = Number(fileIndexRaw);
@@ -58,6 +84,15 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
58
84
  return reply.send(stream);
59
85
  }
60
86
 
87
+ /**
88
+ * Attach event listeners that release the file reference exactly once when
89
+ * the stream or the underlying HTTP connection closes.
90
+ *
91
+ * @param {import("node:stream").Readable} stream
92
+ * @param {import("fastify").FastifyReply} reply
93
+ * @param {() => void} release
94
+ * @returns {void}
95
+ */
61
96
  function bindRelease(stream, reply, release) {
62
97
  let released = false;
63
98
  const releaseOnce = () => {
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Serve HLS playlist and segment files from an active transcode session.
3
+ *
4
+ * Polls until the requested file appears (up to 15 s) so that HLS clients
5
+ * do not receive a 404 during the ffmpeg warmup phase.
6
+ *
7
+ * GET /transcode/:sessionId/:fileName
8
+ *
9
+ * @param {import("fastify").FastifyRequest} req
10
+ * @param {import("fastify").FastifyReply} reply
11
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
12
+ * @returns {Promise<void>}
13
+ */
1
14
  export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
2
15
  const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
3
16
  const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
@@ -23,6 +36,16 @@ export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionMana
23
36
  return reply.send(result.stream);
24
37
  }
25
38
 
39
+ /**
40
+ * Poll `hlsSessionManager.getFileStream()` until the file is available,
41
+ * the session fails, or the timeout elapses.
42
+ *
43
+ * @param {import("../../../services/hls-session-manager.js").HlsSessionManager} hlsSessionManager
44
+ * @param {string} sessionId
45
+ * @param {string} fileName
46
+ * @param {number} timeoutMs
47
+ * @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
48
+ */
26
49
  async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
27
50
  const startedAt = Date.now();
28
51
  while (Date.now() - startedAt < timeoutMs) {
@@ -35,6 +58,12 @@ async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeou
35
58
  return { kind: "warming-up" };
36
59
  }
37
60
 
61
+ /**
62
+ * Resolve after a given number of milliseconds.
63
+ *
64
+ * @param {number} ms
65
+ * @returns {Promise<void>}
66
+ */
38
67
  function delay(ms) {
39
68
  return new Promise((resolve) => {
40
69
  setTimeout(resolve, ms);
package/server.js CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * @file Proxy HTTP server bootstrap.
3
+ *
4
+ * Creates and configures the Fastify application, registers all routes and
5
+ * plugins, then starts listening on the first available port at or above the
6
+ * requested one.
7
+ */
8
+
1
9
  import Fastify from "fastify";
2
10
  import fastifyCors from "@fastify/cors";
3
11
  import fastifyHelmet from "@fastify/helmet";
@@ -23,6 +31,13 @@ const __filename = fileURLToPath(import.meta.url);
23
31
  const __dirname = path.dirname(__filename);
24
32
  const publicRoot = path.resolve(__dirname, "./public");
25
33
 
34
+ /**
35
+ * Build a list of candidate port numbers starting at `startPort`.
36
+ *
37
+ * @param {number} startPort
38
+ * @param {number} [maxAttempts=51]
39
+ * @returns {number[]}
40
+ */
26
41
  function buildPortCandidates(startPort, maxAttempts = 51) {
27
42
  const ports = [];
28
43
  for (let index = 0; index < maxAttempts; index += 1) {
@@ -31,6 +46,20 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
31
46
  return ports;
32
47
  }
33
48
 
49
+ /**
50
+ * @typedef {Object} ProxyServerOptions
51
+ * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
52
+ * @property {number} port - Preferred listen port.
53
+ * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
54
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
55
+ */
56
+
57
+ /**
58
+ * Create, configure, and start the proxy HTTP server.
59
+ *
60
+ * @param {ProxyServerOptions} options
61
+ * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
62
+ */
34
63
  export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }) {
35
64
  const app = Fastify({
36
65
  bodyLimit: 10 * 1024 * 1024