@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 +89 -14
- 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 +18 -0
- 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 +207 -7
- 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$/;
|
|
@@ -17,12 +27,25 @@ const VIDEO_TRANSCODE_PRESET = "superfast";
|
|
|
17
27
|
const VIDEO_TRANSCODE_CRF = "24";
|
|
18
28
|
const VIDEO_TRANSCODE_FPS = 24;
|
|
19
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve after a given number of milliseconds.
|
|
32
|
+
*
|
|
33
|
+
* @param {number} ms
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
20
36
|
function delay(ms) {
|
|
21
37
|
return new Promise((resolve) => {
|
|
22
38
|
setTimeout(resolve, ms);
|
|
23
39
|
});
|
|
24
40
|
}
|
|
25
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
|
+
*/
|
|
26
49
|
function waitForChildExit(child, timeoutMs = 2_000) {
|
|
27
50
|
return new Promise((resolve) => {
|
|
28
51
|
let settled = false;
|
|
@@ -38,6 +61,13 @@ function waitForChildExit(child, timeoutMs = 2_000) {
|
|
|
38
61
|
});
|
|
39
62
|
}
|
|
40
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
|
+
*/
|
|
41
71
|
function toLoopbackHost(host) {
|
|
42
72
|
if (host === "0.0.0.0" || host === "::") {
|
|
43
73
|
return "127.0.0.1";
|
|
@@ -45,6 +75,13 @@ function toLoopbackHost(host) {
|
|
|
45
75
|
return host;
|
|
46
76
|
}
|
|
47
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
|
+
*/
|
|
48
85
|
function buildHttpBaseUrl(host, port) {
|
|
49
86
|
const url = new URL("http://localhost");
|
|
50
87
|
url.hostname = toLoopbackHost(host);
|
|
@@ -52,18 +89,44 @@ function buildHttpBaseUrl(host, port) {
|
|
|
52
89
|
return url.origin;
|
|
53
90
|
}
|
|
54
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
|
+
*/
|
|
55
98
|
function createSessionDirPath(sessionId) {
|
|
56
99
|
return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
|
|
57
100
|
}
|
|
58
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
|
+
*/
|
|
59
108
|
function isSafeSessionId(value) {
|
|
60
109
|
return /^[a-f0-9-]{36}$/i.test(value);
|
|
61
110
|
}
|
|
62
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
|
+
*/
|
|
63
119
|
function isSafeFileName(fileName) {
|
|
64
120
|
return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
|
|
65
121
|
}
|
|
66
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
|
+
*/
|
|
67
130
|
function parseFfmpegTimestamp(value) {
|
|
68
131
|
if (!value || typeof value !== "string") {
|
|
69
132
|
return null;
|
|
@@ -81,6 +144,13 @@ function parseFfmpegTimestamp(value) {
|
|
|
81
144
|
return hours * 3600 + minutes * 60 + seconds;
|
|
82
145
|
}
|
|
83
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
|
+
*/
|
|
84
154
|
function parseFfmpegDurationSeconds(stderrText) {
|
|
85
155
|
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
86
156
|
return null;
|
|
@@ -98,6 +168,12 @@ function parseFfmpegDurationSeconds(stderrText) {
|
|
|
98
168
|
return hours * 3600 + minutes * 60 + seconds;
|
|
99
169
|
}
|
|
100
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
|
+
*/
|
|
101
177
|
function formatSeconds(seconds) {
|
|
102
178
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
103
179
|
return "n/a";
|
|
@@ -109,6 +185,13 @@ function formatSeconds(seconds) {
|
|
|
109
185
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
|
110
186
|
}
|
|
111
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
|
+
*/
|
|
112
195
|
function computeProgressMetrics(processedSeconds, totalSeconds) {
|
|
113
196
|
const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
|
|
114
197
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
|
@@ -125,6 +208,14 @@ function computeProgressMetrics(processedSeconds, totalSeconds) {
|
|
|
125
208
|
};
|
|
126
209
|
}
|
|
127
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
|
+
*/
|
|
128
219
|
async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
|
|
129
220
|
return new Promise((resolve) => {
|
|
130
221
|
const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
|
|
@@ -179,7 +270,43 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
179
270
|
return value;
|
|
180
271
|
}
|
|
181
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
|
+
*/
|
|
182
306
|
export class HlsSessionManager {
|
|
307
|
+
/**
|
|
308
|
+
* @param {HlsSessionManagerOptions} options
|
|
309
|
+
*/
|
|
183
310
|
constructor({
|
|
184
311
|
enabled,
|
|
185
312
|
ffmpegBin,
|
|
@@ -203,6 +330,24 @@ export class HlsSessionManager {
|
|
|
203
330
|
this.cleanupTimer.unref();
|
|
204
331
|
}
|
|
205
332
|
|
|
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
|
+
*/
|
|
206
351
|
async createOrGetSession({
|
|
207
352
|
sourceKey,
|
|
208
353
|
fileIndex,
|
|
@@ -377,8 +522,8 @@ export class HlsSessionManager {
|
|
|
377
522
|
session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
|
|
378
523
|
if (shouldLog) {
|
|
379
524
|
session.progress.lastLoggedAt = session.progress.updatedAt;
|
|
380
|
-
|
|
381
|
-
`
|
|
525
|
+
logger.info(
|
|
526
|
+
`transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
|
|
382
527
|
`(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
|
|
383
528
|
` speed=${session.progress.speed || "n/a"}`
|
|
384
529
|
);
|
|
@@ -390,7 +535,7 @@ export class HlsSessionManager {
|
|
|
390
535
|
const line = String(chunk).trim();
|
|
391
536
|
if (line.length > 0) {
|
|
392
537
|
session.lastError = line;
|
|
393
|
-
|
|
538
|
+
logger.warn(`ffmpeg: ${line}`);
|
|
394
539
|
}
|
|
395
540
|
});
|
|
396
541
|
|
|
@@ -399,7 +544,7 @@ export class HlsSessionManager {
|
|
|
399
544
|
session.lastError = error instanceof Error ? error.message : String(error);
|
|
400
545
|
session.progress.state = "failed";
|
|
401
546
|
session.progress.updatedAt = Date.now();
|
|
402
|
-
|
|
547
|
+
logger.error(`ffmpeg process error: ${session.lastError}`);
|
|
403
548
|
});
|
|
404
549
|
|
|
405
550
|
ffmpeg.on("exit", (code) => {
|
|
@@ -439,6 +584,14 @@ export class HlsSessionManager {
|
|
|
439
584
|
return `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${VIDEO_TRANSCODE_FPS}`;
|
|
440
585
|
}
|
|
441
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
|
+
*/
|
|
442
595
|
async waitUntilReady(session) {
|
|
443
596
|
const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
|
|
444
597
|
const deadline = Date.now() + this.startupWaitMs;
|
|
@@ -463,6 +616,18 @@ export class HlsSessionManager {
|
|
|
463
616
|
throw new Error("HLS playlist is still warming up.");
|
|
464
617
|
}
|
|
465
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
|
+
*/
|
|
466
631
|
async getFileStream(sessionId, fileName) {
|
|
467
632
|
if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName)) {
|
|
468
633
|
return { kind: "not-found" };
|
|
@@ -495,6 +660,12 @@ export class HlsSessionManager {
|
|
|
495
660
|
};
|
|
496
661
|
}
|
|
497
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
|
+
*/
|
|
498
669
|
async cleanupExpired() {
|
|
499
670
|
const now = Date.now();
|
|
500
671
|
const idsToDispose = [];
|
|
@@ -508,6 +679,13 @@ export class HlsSessionManager {
|
|
|
508
679
|
}
|
|
509
680
|
}
|
|
510
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
|
+
*/
|
|
511
689
|
getSessionProgress(sessionId) {
|
|
512
690
|
if (!isSafeSessionId(sessionId)) {
|
|
513
691
|
return null;
|
|
@@ -541,6 +719,15 @@ export class HlsSessionManager {
|
|
|
541
719
|
};
|
|
542
720
|
}
|
|
543
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
|
+
*/
|
|
544
731
|
async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
|
|
545
732
|
if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
|
|
546
733
|
return false;
|
|
@@ -555,8 +742,8 @@ export class HlsSessionManager {
|
|
|
555
742
|
session.consumers.delete(consumerId);
|
|
556
743
|
session.lastAccessedAt = Date.now();
|
|
557
744
|
const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
|
|
558
|
-
|
|
559
|
-
`
|
|
745
|
+
logger.info(
|
|
746
|
+
`consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
|
|
560
747
|
`remaining=${session.consumers.size}`
|
|
561
748
|
);
|
|
562
749
|
if (session.consumers.size > 0) {
|
|
@@ -566,6 +753,12 @@ export class HlsSessionManager {
|
|
|
566
753
|
return true;
|
|
567
754
|
}
|
|
568
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
|
+
*/
|
|
569
762
|
async disposeSession(sessionId) {
|
|
570
763
|
const session = this.sessionsById.get(sessionId);
|
|
571
764
|
if (!session) {
|
|
@@ -583,10 +776,17 @@ export class HlsSessionManager {
|
|
|
583
776
|
await rm(session.dirPath, { recursive: true, force: true });
|
|
584
777
|
} catch (error) {
|
|
585
778
|
const message = error instanceof Error ? error.message : String(error);
|
|
586
|
-
|
|
779
|
+
logger.warn(`failed to cleanup HLS temp dir: ${message}`);
|
|
587
780
|
}
|
|
588
781
|
}
|
|
589
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
|
+
*/
|
|
590
790
|
async disposeAll() {
|
|
591
791
|
clearInterval(this.cleanupTimer);
|
|
592
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) {
|