@torrent-tv/proxy 2.0.0 → 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.
@@ -1,9 +1,19 @@
1
+ /**
2
+ * @file HLS transcode session manager.
3
+ *
4
+ * Spawns one ffmpeg process per unique source+settings combination and
5
+ * streams the resulting HLS playlist and segments from a temporary directory.
6
+ * Sessions are expired automatically via a periodic cleanup interval, or
7
+ * immediately when all registered consumers release them.
8
+ */
9
+
1
10
  import { createReadStream } from "node:fs";
2
11
  import { access, mkdir, readdir, readFile, rm } from "node:fs/promises";
3
12
  import os from "node:os";
4
13
  import path from "node:path";
5
14
  import { randomUUID } from "node:crypto";
6
15
  import { spawn } from "node:child_process";
16
+ import { logger } from "../utils/logger.js";
7
17
 
8
18
  const PLAYLIST_FILE_NAME = "index.m3u8";
9
19
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -13,13 +23,29 @@ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
13
23
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
14
24
  const MICROSECONDS_PER_SECOND = 1_000_000;
15
25
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
16
-
26
+ const VIDEO_TRANSCODE_PRESET = "superfast";
27
+ const VIDEO_TRANSCODE_CRF = "24";
28
+ const VIDEO_TRANSCODE_FPS = 24;
29
+
30
+ /**
31
+ * Resolve after a given number of milliseconds.
32
+ *
33
+ * @param {number} ms
34
+ * @returns {Promise<void>}
35
+ */
17
36
  function delay(ms) {
18
37
  return new Promise((resolve) => {
19
38
  setTimeout(resolve, ms);
20
39
  });
21
40
  }
22
41
 
42
+ /**
43
+ * Wait for a child process to exit, with a hard timeout fallback.
44
+ *
45
+ * @param {import("node:child_process").ChildProcess} child
46
+ * @param {number} [timeoutMs=2000]
47
+ * @returns {Promise<void>}
48
+ */
23
49
  function waitForChildExit(child, timeoutMs = 2_000) {
24
50
  return new Promise((resolve) => {
25
51
  let settled = false;
@@ -35,6 +61,13 @@ function waitForChildExit(child, timeoutMs = 2_000) {
35
61
  });
36
62
  }
37
63
 
64
+ /**
65
+ * Convert a bind-all host address to the loopback address so that
66
+ * the HLS input URL is always reachable from the same machine.
67
+ *
68
+ * @param {string} host
69
+ * @returns {string}
70
+ */
38
71
  function toLoopbackHost(host) {
39
72
  if (host === "0.0.0.0" || host === "::") {
40
73
  return "127.0.0.1";
@@ -42,6 +75,13 @@ function toLoopbackHost(host) {
42
75
  return host;
43
76
  }
44
77
 
78
+ /**
79
+ * Build the HTTP base URL (scheme + host + port) for the local proxy server.
80
+ *
81
+ * @param {string} host - Bind host (may be "0.0.0.0" or "::").
82
+ * @param {number} port
83
+ * @returns {string} e.g. "http://127.0.0.1:9090"
84
+ */
45
85
  function buildHttpBaseUrl(host, port) {
46
86
  const url = new URL("http://localhost");
47
87
  url.hostname = toLoopbackHost(host);
@@ -49,18 +89,44 @@ function buildHttpBaseUrl(host, port) {
49
89
  return url.origin;
50
90
  }
51
91
 
92
+ /**
93
+ * Return the temporary directory path for a given HLS session.
94
+ *
95
+ * @param {string} sessionId - UUID of the session.
96
+ * @returns {string}
97
+ */
52
98
  function createSessionDirPath(sessionId) {
53
99
  return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
54
100
  }
55
101
 
102
+ /**
103
+ * Guard against path traversal by validating that a session ID is a UUID.
104
+ *
105
+ * @param {unknown} value
106
+ * @returns {boolean}
107
+ */
56
108
  function isSafeSessionId(value) {
57
109
  return /^[a-f0-9-]{36}$/i.test(value);
58
110
  }
59
111
 
112
+ /**
113
+ * Guard against path traversal by restricting file names to the known
114
+ * playlist and segment patterns produced by ffmpeg.
115
+ *
116
+ * @param {string} fileName
117
+ * @returns {boolean}
118
+ */
60
119
  function isSafeFileName(fileName) {
61
120
  return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
62
121
  }
63
122
 
123
+ /**
124
+ * Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
125
+ * Returns `null` if the value is absent or malformed.
126
+ *
127
+ * @param {string | undefined} value
128
+ * @returns {number | null}
129
+ */
64
130
  function parseFfmpegTimestamp(value) {
65
131
  if (!value || typeof value !== "string") {
66
132
  return null;
@@ -78,6 +144,13 @@ function parseFfmpegTimestamp(value) {
78
144
  return hours * 3600 + minutes * 60 + seconds;
79
145
  }
80
146
 
147
+ /**
148
+ * Extract the total duration in seconds from ffmpeg stderr output.
149
+ * Returns `null` if the duration line is absent or unparseable.
150
+ *
151
+ * @param {string} stderrText
152
+ * @returns {number | null}
153
+ */
81
154
  function parseFfmpegDurationSeconds(stderrText) {
82
155
  if (typeof stderrText !== "string" || stderrText.length === 0) {
83
156
  return null;
@@ -95,6 +168,12 @@ function parseFfmpegDurationSeconds(stderrText) {
95
168
  return hours * 3600 + minutes * 60 + seconds;
96
169
  }
97
170
 
171
+ /**
172
+ * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
173
+ *
174
+ * @param {number} seconds
175
+ * @returns {string}
176
+ */
98
177
  function formatSeconds(seconds) {
99
178
  if (!Number.isFinite(seconds) || seconds < 0) {
100
179
  return "n/a";
@@ -106,6 +185,13 @@ function formatSeconds(seconds) {
106
185
  return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
107
186
  }
108
187
 
188
+ /**
189
+ * Compute derived progress metrics from raw ffmpeg output values.
190
+ *
191
+ * @param {number} processedSeconds - Seconds of content encoded so far.
192
+ * @param {number | null} totalSeconds - Total duration, or `null` if unknown.
193
+ * @returns {{ totalSeconds: number | null, percent: number | null, remainingSeconds: number | null, processedSeconds: number }}
194
+ */
109
195
  function computeProgressMetrics(processedSeconds, totalSeconds) {
110
196
  const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
111
197
  if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
@@ -122,6 +208,14 @@ function computeProgressMetrics(processedSeconds, totalSeconds) {
122
208
  };
123
209
  }
124
210
 
211
+ /**
212
+ * Run a short ffmpeg probe to extract the total duration of a stream.
213
+ * Times out after 8 s and returns `null` on failure.
214
+ *
215
+ * @param {string} ffmpegBin - Path to the ffmpeg executable.
216
+ * @param {string | URL} inputUrl - URL of the stream to probe.
217
+ * @returns {Promise<number | null>} Duration in seconds, or `null`.
218
+ */
125
219
  async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
126
220
  return new Promise((resolve) => {
127
221
  const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
@@ -176,7 +270,43 @@ function normalizeLogFileName(fileName, fileIndex) {
176
270
  return value;
177
271
  }
178
272
 
273
+ /**
274
+ * @typedef {Object} HlsSessionManagerOptions
275
+ * @property {boolean} enabled - Whether HLS transcoding is enabled.
276
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
277
+ * @property {string} localBindHost - Host the proxy HTTP server is bound to.
278
+ * @property {number} localPort - Port the proxy HTTP server is listening on.
279
+ * @property {number} [segmentDurationSec] - HLS segment length in seconds.
280
+ * @property {number} [sessionTtlMs] - Session idle TTL in milliseconds.
281
+ * @property {number} [startupWaitMs] - Max time to wait for the first playlist file.
282
+ */
283
+
284
+ /**
285
+ * @typedef {Object} HlsSession
286
+ * @property {string} id - UUID of the session.
287
+ * @property {string} sourceMapKey - Cache key combining source + transcode settings.
288
+ * @property {string} fileName - Display name of the file being transcoded.
289
+ * @property {string} dirPath - Temp directory containing HLS output.
290
+ * @property {"starting" | "ready" | "failed" | "disposed"} state
291
+ * @property {number} startedAt - Unix ms timestamp when the session was created.
292
+ * @property {number} lastAccessedAt - Unix ms timestamp of the last consumer access.
293
+ * @property {import("node:child_process").ChildProcess} ffmpeg
294
+ * @property {string} lastError
295
+ * @property {Set<string>} consumers - Consumer IDs currently using this session.
296
+ * @property {object} progress - Live progress metrics updated from ffmpeg stdout.
297
+ */
298
+
299
+ /**
300
+ * Manages HLS transcode sessions backed by ffmpeg child processes.
301
+ *
302
+ * One session is created per unique (source, fileIndex, transcode settings)
303
+ * combination. Sessions are reused across consumers and are automatically
304
+ * expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
305
+ */
179
306
  export class HlsSessionManager {
307
+ /**
308
+ * @param {HlsSessionManagerOptions} options
309
+ */
180
310
  constructor({
181
311
  enabled,
182
312
  ffmpegBin,
@@ -200,14 +330,50 @@ export class HlsSessionManager {
200
330
  this.cleanupTimer.unref();
201
331
  }
202
332
 
203
- async createOrGetSession({ sourceKey, fileIndex, transcodeVideo = false, consumerId = "", fileName = "" }) {
333
+ /**
334
+ * Return an existing HLS session for the given source/settings, or create
335
+ * one by spawning a new ffmpeg process.
336
+ *
337
+ * Throws with `error.code === "TRANSCODE_DISABLED"` when transcoding is
338
+ * disabled on this proxy instance.
339
+ *
340
+ * @param {object} options
341
+ * @param {string} options.sourceKey - Registry source key.
342
+ * @param {number} options.fileIndex - Zero-based file index in the torrent.
343
+ * @param {boolean} [options.transcodeVideo=false]
344
+ * @param {boolean} [options.transcodeAudio=false]
345
+ * @param {string} [options.consumerId=""] - Caller ID for reference counting.
346
+ * @param {string} [options.fileName=""] - Display name for log output.
347
+ * @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
348
+ * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
349
+ * @returns {Promise<HlsSession>}
350
+ */
351
+ async createOrGetSession({
352
+ sourceKey,
353
+ fileIndex,
354
+ transcodeVideo = false,
355
+ transcodeAudio = false,
356
+ consumerId = "",
357
+ fileName = "",
358
+ targetWidth = 0,
359
+ targetHeight = 0
360
+ }) {
204
361
  if (!this.enabled) {
205
362
  const error = new Error("Audio transcoding is disabled on this proxy.");
206
363
  error.code = "TRANSCODE_DISABLED";
207
364
  throw error;
208
365
  }
209
366
 
210
- const sourceMapKey = `${sourceKey}:${fileIndex}:${transcodeVideo ? "video" : "audio"}`;
367
+ const normalizedTargetWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0;
368
+ const normalizedTargetHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0;
369
+ const sourceMapKey = [
370
+ sourceKey,
371
+ String(fileIndex),
372
+ transcodeVideo ? "video" : "audio",
373
+ transcodeAudio ? "a1" : "a0",
374
+ String(normalizedTargetWidth),
375
+ String(normalizedTargetHeight)
376
+ ].join(":");
211
377
  const existingId = this.sessionIdBySource.get(sourceMapKey);
212
378
  if (existingId) {
213
379
  const existing = this.sessionsById.get(existingId);
@@ -238,8 +404,22 @@ export class HlsSessionManager {
238
404
  const durationSeconds = await probeInputDurationSeconds(this.ffmpegBin, inputUrl.toString());
239
405
 
240
406
  const videoCodecArgs = transcodeVideo
241
- ? ["-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"]
407
+ ? [
408
+ "-vf",
409
+ this.#buildVideoFilter(normalizedTargetWidth, normalizedTargetHeight),
410
+ "-c:v",
411
+ "libx264",
412
+ "-preset",
413
+ VIDEO_TRANSCODE_PRESET,
414
+ "-crf",
415
+ VIDEO_TRANSCODE_CRF,
416
+ "-pix_fmt",
417
+ "yuv420p"
418
+ ]
242
419
  : ["-c:v", "copy"];
420
+ const audioCodecArgs = transcodeAudio
421
+ ? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
422
+ : ["-c:a", "copy"];
243
423
 
244
424
  const args = [
245
425
  "-hide_banner",
@@ -255,12 +435,7 @@ export class HlsSessionManager {
255
435
  "-map",
256
436
  "0:a:0?",
257
437
  ...videoCodecArgs,
258
- "-c:a",
259
- "aac",
260
- "-ac",
261
- "2",
262
- "-b:a",
263
- "160k",
438
+ ...audioCodecArgs,
264
439
  "-f",
265
440
  "hls",
266
441
  "-hls_time",
@@ -347,8 +522,8 @@ export class HlsSessionManager {
347
522
  session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
348
523
  if (shouldLog) {
349
524
  session.progress.lastLoggedAt = session.progress.updatedAt;
350
- console.log(
351
- `[proxy-client] transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
525
+ logger.info(
526
+ `transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
352
527
  `(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
353
528
  ` speed=${session.progress.speed || "n/a"}`
354
529
  );
@@ -360,7 +535,7 @@ export class HlsSessionManager {
360
535
  const line = String(chunk).trim();
361
536
  if (line.length > 0) {
362
537
  session.lastError = line;
363
- console.warn(`[proxy-client] ffmpeg: ${line}`);
538
+ logger.warn(`ffmpeg: ${line}`);
364
539
  }
365
540
  });
366
541
 
@@ -369,7 +544,7 @@ export class HlsSessionManager {
369
544
  session.lastError = error instanceof Error ? error.message : String(error);
370
545
  session.progress.state = "failed";
371
546
  session.progress.updatedAt = Date.now();
372
- console.error(`[proxy-client] ffmpeg process error: ${session.lastError}`);
547
+ logger.error(`ffmpeg process error: ${session.lastError}`);
373
548
  });
374
549
 
375
550
  ffmpeg.on("exit", (code) => {
@@ -403,6 +578,20 @@ export class HlsSessionManager {
403
578
  }
404
579
  }
405
580
 
581
+ #buildVideoFilter(targetWidth, targetHeight) {
582
+ const safeWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
583
+ const safeHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
584
+ return `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${VIDEO_TRANSCODE_FPS}`;
585
+ }
586
+
587
+ /**
588
+ * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
589
+ * header, or until the session fails, or until the startup timeout elapses.
590
+ * Throws with message `"HLS playlist is still warming up."` on timeout.
591
+ *
592
+ * @param {HlsSession} session
593
+ * @returns {Promise<void>}
594
+ */
406
595
  async waitUntilReady(session) {
407
596
  const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
408
597
  const deadline = Date.now() + this.startupWaitMs;
@@ -427,6 +616,18 @@ export class HlsSessionManager {
427
616
  throw new Error("HLS playlist is still warming up.");
428
617
  }
429
618
 
619
+ /**
620
+ * Open a read stream for an HLS segment or playlist file from a session.
621
+ *
622
+ * @param {string} sessionId
623
+ * @param {string} fileName - Must match the playlist or segment name pattern.
624
+ * @returns {Promise<
625
+ * | { kind: "not-found" }
626
+ * | { kind: "warming-up" }
627
+ * | { kind: "failed"; message: string }
628
+ * | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
629
+ * >}
630
+ */
430
631
  async getFileStream(sessionId, fileName) {
431
632
  if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName)) {
432
633
  return { kind: "not-found" };
@@ -459,6 +660,12 @@ export class HlsSessionManager {
459
660
  };
460
661
  }
461
662
 
663
+ /**
664
+ * Dispose all sessions that have been idle longer than `sessionTtlMs`.
665
+ * Called automatically on the cleanup interval.
666
+ *
667
+ * @returns {Promise<void>}
668
+ */
462
669
  async cleanupExpired() {
463
670
  const now = Date.now();
464
671
  const idsToDispose = [];
@@ -472,6 +679,13 @@ export class HlsSessionManager {
472
679
  }
473
680
  }
474
681
 
682
+ /**
683
+ * Return a progress snapshot for the given session, or `null` if not found.
684
+ * Also refreshes `lastAccessedAt` to prevent the session from expiring.
685
+ *
686
+ * @param {string} sessionId
687
+ * @returns {object | null}
688
+ */
475
689
  getSessionProgress(sessionId) {
476
690
  if (!isSafeSessionId(sessionId)) {
477
691
  return null;
@@ -505,6 +719,15 @@ export class HlsSessionManager {
505
719
  };
506
720
  }
507
721
 
722
+ /**
723
+ * Remove a consumer from a session. Disposes the session when the last
724
+ * consumer leaves.
725
+ *
726
+ * @param {string} sessionId
727
+ * @param {string} [consumerId=""]
728
+ * @param {string} [reason=""] - Human-readable reason shown in logs.
729
+ * @returns {Promise<boolean>} `false` if the session was not found.
730
+ */
508
731
  async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
509
732
  if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
510
733
  return false;
@@ -519,8 +742,8 @@ export class HlsSessionManager {
519
742
  session.consumers.delete(consumerId);
520
743
  session.lastAccessedAt = Date.now();
521
744
  const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
522
- console.log(
523
- `[proxy-client] consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
745
+ logger.info(
746
+ `consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
524
747
  `remaining=${session.consumers.size}`
525
748
  );
526
749
  if (session.consumers.size > 0) {
@@ -530,6 +753,12 @@ export class HlsSessionManager {
530
753
  return true;
531
754
  }
532
755
 
756
+ /**
757
+ * Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
758
+ *
759
+ * @param {string} sessionId
760
+ * @returns {Promise<void>}
761
+ */
533
762
  async disposeSession(sessionId) {
534
763
  const session = this.sessionsById.get(sessionId);
535
764
  if (!session) {
@@ -547,10 +776,17 @@ export class HlsSessionManager {
547
776
  await rm(session.dirPath, { recursive: true, force: true });
548
777
  } catch (error) {
549
778
  const message = error instanceof Error ? error.message : String(error);
550
- console.warn(`[proxy-client] failed to cleanup HLS temp dir: ${message}`);
779
+ logger.warn(`failed to cleanup HLS temp dir: ${message}`);
551
780
  }
552
781
  }
553
782
 
783
+ /**
784
+ * Stop the cleanup timer, dispose all active sessions, and attempt to
785
+ * remove the shared temp root directory if it is empty.
786
+ * Called by Fastify's `onClose` hook during graceful shutdown.
787
+ *
788
+ * @returns {Promise<void>}
789
+ */
554
790
  async disposeAll() {
555
791
  clearInterval(this.cleanupTimer);
556
792
  const activeIds = Array.from(this.sessionsById.keys());
@@ -1,7 +1,22 @@
1
+ /**
2
+ * @file Playback planner service.
3
+ *
4
+ * Determines whether a torrent file can be served directly or requires
5
+ * HLS audio transcoding by probing the stream codecs with ffmpeg.
6
+ * Results are cached indefinitely (keyed by source + file index).
7
+ */
8
+
1
9
  import { spawn } from "node:child_process";
2
10
 
11
+ /** Audio codecs that browsers can decode natively without transcoding. */
3
12
  const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
4
13
 
14
+ /**
15
+ * Parse audio and video codec names from ffmpeg stderr output.
16
+ *
17
+ * @param {string} ffmpegOutput
18
+ * @returns {{ audioCodec: string, videoCodec: string }}
19
+ */
5
20
  function parseStreamCodecs(ffmpegOutput) {
6
21
  const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
7
22
  const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
@@ -11,6 +26,17 @@ function parseStreamCodecs(ffmpegOutput) {
11
26
  };
12
27
  }
13
28
 
29
+ /**
30
+ * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
31
+ * Times out after `timeoutMs` and returns empty strings on failure.
32
+ *
33
+ * @param {object} options
34
+ * @param {string} options.ffmpegBin
35
+ * @param {string} options.inputUrl
36
+ * @param {string} [options.userAgent=""]
37
+ * @param {number} [options.timeoutMs=8000]
38
+ * @returns {Promise<{ audioCodec: string, videoCodec: string }>}
39
+ */
14
40
  function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
15
41
  return new Promise((resolve) => {
16
42
  const args = ["-hide_banner", "-loglevel", "info"];
@@ -57,6 +83,14 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
57
83
  });
58
84
  }
59
85
 
86
+ /**
87
+ * Build the direct stream URL for a source file served by the local proxy.
88
+ *
89
+ * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
90
+ * @param {string} sourceKey
91
+ * @param {number} fileIndex
92
+ * @returns {string}
93
+ */
60
94
  function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
61
95
  const directUrl = new URL("/stream", `${localBaseUrl}/`);
62
96
  directUrl.searchParams.set("sourceKey", sourceKey);
@@ -64,6 +98,31 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
64
98
  return directUrl.toString();
65
99
  }
66
100
 
101
+ /**
102
+ * @typedef {Object} PlaybackPlan
103
+ * @property {"direct" | "hls"} mode
104
+ * @property {string} directUrl
105
+ * @property {string} reason - Human-readable explanation of the chosen mode.
106
+ * @property {string} audioCodec
107
+ * @property {string} videoCodec
108
+ */
109
+
110
+ /**
111
+ * @typedef {Object} PlaybackPlannerOptions
112
+ * @property {string} ffmpegBin
113
+ * @property {boolean} transcodeAudioEnabled
114
+ * @property {string} localBaseUrl
115
+ * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
116
+ * @property {import("./torrent-pool.js").TorrentPool} torrentPool
117
+ */
118
+
119
+ /**
120
+ * Create a playback planner that decides the optimal streaming mode for
121
+ * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
122
+ *
123
+ * @param {PlaybackPlannerOptions} options
124
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
125
+ */
67
126
  export function createPlaybackPlanner({
68
127
  ffmpegBin,
69
128
  transcodeAudioEnabled,
@@ -71,9 +130,21 @@ export function createPlaybackPlanner({
71
130
  sourceRegistry,
72
131
  torrentPool
73
132
  }) {
133
+ /** @type {Map<string, PlaybackPlan>} */
74
134
  const cache = new Map();
75
135
 
76
136
  return {
137
+ /**
138
+ * Return the playback plan for the given source file.
139
+ * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
140
+ * when the source or file cannot be located.
141
+ *
142
+ * @param {object} params
143
+ * @param {string} params.sourceKey
144
+ * @param {number} params.fileIndex
145
+ * @param {string} [params.userAgent=""]
146
+ * @returns {Promise<PlaybackPlan>}
147
+ */
77
148
  async getPlan({ sourceKey, fileIndex, userAgent = "" }) {
78
149
  const cacheKey = `${sourceKey}:${fileIndex}`;
79
150
  const cached = cache.get(cacheKey);
@@ -1,21 +1,56 @@
1
+ /**
2
+ * @file HTTP client for the registry server API.
3
+ *
4
+ * Handles proxy registration and periodic heartbeat requests.
5
+ * The auth token is sent as the `x-proxy-token` request header.
6
+ */
7
+
8
+ /**
9
+ * Build a full URL to a registry API endpoint.
10
+ *
11
+ * @param {string} serverUrl - Base URL of the registry server.
12
+ * @param {string} pathname - Relative path (e.g. "api/proxy-clients/register").
13
+ * @returns {URL}
14
+ */
1
15
  function buildRegistryUrl(serverUrl, pathname) {
2
16
  return new URL(pathname, ensureBaseUrl(serverUrl));
3
17
  }
4
18
 
19
+ /**
20
+ * Ensure the server URL ends with a trailing slash so that relative
21
+ * paths in `new URL(pathname, base)` resolve correctly.
22
+ *
23
+ * @param {string} serverUrl
24
+ * @returns {string}
25
+ */
5
26
  function ensureBaseUrl(serverUrl) {
6
27
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
7
28
  }
8
29
 
30
+ /**
31
+ * @typedef {Object} RegisterClientParams
32
+ * @property {string} serverUrl - Base URL of the registry server.
33
+ * @property {string} id - Stable unique identifier for this proxy.
34
+ * @property {string} name - Human-readable display name.
35
+ * @property {string} baseUrl - Publicly reachable base URL of this proxy.
36
+ * @property {string} token - Auth token sent as `x-proxy-token`.
37
+ */
38
+
39
+ /**
40
+ * Register this proxy with the registry server.
41
+ * Throws if the server responds with a non-2xx status.
42
+ *
43
+ * @param {RegisterClientParams} params
44
+ * @returns {Promise<{ client: { id: string, name: string, baseUrl: string, createdAt: string, lastSeenAt: string } }>}
45
+ */
9
46
  export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
10
47
  const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/register"), {
11
48
  method: "POST",
12
- headers: { "Content-Type": "application/json" },
13
- body: JSON.stringify({
14
- id,
15
- name,
16
- baseUrl,
17
- token
18
- })
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ "x-proxy-token": token
52
+ },
53
+ body: JSON.stringify({ id, name, baseUrl })
19
54
  });
20
55
 
21
56
  if (!response.ok) {
@@ -26,15 +61,30 @@ export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
26
61
  return response.json();
27
62
  }
28
63
 
64
+ /**
65
+ * @typedef {Object} SendHeartbeatParams
66
+ * @property {string} serverUrl - Base URL of the registry server.
67
+ * @property {string} id - Proxy ID to refresh.
68
+ * @property {string} token - Auth token sent as `x-proxy-token`.
69
+ */
70
+
71
+ /**
72
+ * Send a heartbeat to the registry server to refresh `lastSeenAt`.
73
+ * Returns the HTTP status code, or `null` if the request failed entirely
74
+ * (e.g. network error).
75
+ *
76
+ * @param {SendHeartbeatParams} params
77
+ * @returns {Promise<number | null>}
78
+ */
29
79
  export async function sendHeartbeat({ serverUrl, id, token }) {
30
80
  try {
31
81
  const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/heartbeat"), {
32
82
  method: "POST",
33
- headers: { "Content-Type": "application/json" },
34
- body: JSON.stringify({
35
- id,
36
- token
37
- })
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ "x-proxy-token": token
86
+ },
87
+ body: JSON.stringify({ id })
38
88
  });
39
89
  return response.status;
40
90
  } catch (_error) {