@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.
- package/bin/cli.js +63 -13
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +19 -0
- package/routes/api/sources/post.js +18 -0
- package/routes/api/transcode-sessions/post.js +25 -1
- package/routes/api/transcode-sessions/progress/get.js +10 -0
- package/routes/api/transcode-sessions/release/post.js +19 -0
- package/routes/health/get.js +9 -0
- package/routes/healthz/get.js +9 -0
- package/routes/stream/get.js +35 -0
- package/routes/transcode/session-file/get.js +29 -0
- package/server.js +29 -0
- package/services/hls-session-manager.js +253 -17
- package/services/playback-planner.js +71 -0
- package/services/registry-api.js +62 -12
- package/services/torrent-pool.js +72 -2
- package/services/tunnel-client.js +220 -0
- package/store/source-registry.js +41 -0
- package/utils/logger.js +30 -0
- package/utils/parse-range.js +13 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
? [
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
`
|
|
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
|
-
|
|
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);
|
package/services/registry-api.js
CHANGED
|
@@ -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: {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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: {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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) {
|