@torrent-tv/proxy 2.0.1 → 2.1.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,18 @@ 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
+ * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
141
+ *
142
+ * @param {string} signal - Signal name (e.g. "SIGINT").
143
+ * @returns {Promise<void>}
144
+ */
106
145
  async function shutdown(signal) {
107
146
  if (shutdownInProgress) {
108
147
  return;
@@ -112,7 +151,11 @@ async function shutdown(signal) {
112
151
  clearInterval(heartbeatTimer);
113
152
  heartbeatTimer = null;
114
153
  }
115
- console.log(chalk.yellow(`[proxy-client] Received ${signal}, shutting down...`));
154
+ if (tunnelClient) {
155
+ tunnelClient.disconnect();
156
+ tunnelClient = null;
157
+ }
158
+ logger.warn(`Received ${signal}, shutting down...`);
116
159
  try {
117
160
  if (app) {
118
161
  await app.close();
@@ -120,7 +163,7 @@ async function shutdown(signal) {
120
163
  process.exit(0);
121
164
  } catch (error) {
122
165
  const message = error instanceof Error ? error.message : String(error);
123
- console.error(chalk.red(`[proxy-client] Shutdown failed: ${message}`));
166
+ logger.error(`Shutdown failed: ${message}`);
124
167
  process.exit(1);
125
168
  }
126
169
  }
@@ -139,16 +182,23 @@ try {
139
182
  actualPort = started.port;
140
183
  const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
141
184
 
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}`));
185
+ logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
186
+ logger.info(`Advertised direct URL: ${directBaseUrl}`);
144
187
  if (transcodeAudio) {
145
- console.log(
146
- chalk.cyan(`[proxy-client] Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`)
147
- );
188
+ logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
148
189
  }
149
190
 
150
191
  await registerClientSafe();
151
192
 
193
+ tunnelClient = createTunnelClient({
194
+ serverUrl,
195
+ proxyId: clientId,
196
+ token,
197
+ proxyPort: actualPort,
198
+ onLog: (msg) => logger.info(msg)
199
+ });
200
+ tunnelClient.connect();
201
+
152
202
  heartbeatTimer = setInterval(async () => {
153
203
  const status = await sendHeartbeat({
154
204
  serverUrl,
@@ -156,18 +206,18 @@ try {
156
206
  token
157
207
  });
158
208
  if (status === 404) {
159
- console.log(chalk.yellow("[proxy-client] Heartbeat returned 404, re-registering..."));
209
+ logger.warn("Heartbeat returned 404, re-registering...");
160
210
  try {
161
211
  await registerClientSafe();
162
212
  } catch (error) {
163
213
  const message = error instanceof Error ? error.message : String(error);
164
- console.error(chalk.red(`[proxy-client] Re-register failed: ${message}`));
214
+ logger.error(`Re-register failed: ${message}`);
165
215
  }
166
216
  }
167
217
  }, 20_000);
168
218
  } catch (error) {
169
219
  const message = error instanceof Error ? error.message : String(error);
170
- console.error(chalk.red(`[proxy-client] ${message}`));
220
+ logger.error(message);
171
221
  process.exit(1);
172
222
  }
173
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.0.1",
3
+ "version": "2.1.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