@torrent-tv/proxy 2.9.53 → 2.9.54

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,2312 +1,2322 @@
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
-
10
- import { createReadStream } from "node:fs";
11
- import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
12
- import { Readable } from "node:stream";
13
- import os from "node:os";
14
- import path from "node:path";
15
- import { randomUUID } from "node:crypto";
16
- import { spawn } from "node:child_process";
17
- import { logger } from "../utils/logger.js";
18
- import {
19
- softwareDescriptor,
20
- chooseSoftwareEncodeSettings,
21
- pickSoftwarePreset,
22
- TRANSCODE_FPS,
23
- chooseOutputFps
24
- } from "./hwaccel.js";
25
- import {
26
- parseFfmpegDurationSeconds,
27
- parseFfmpegStartTimeSeconds,
28
- parseFfmpegVideoDimensions,
29
- parseFfmpegVideoFps,
30
- parseFfmpegHdr
31
- } from "./ffmpeg-banner.js";
32
-
33
- const PLAYLIST_FILE_NAME = "index.m3u8";
34
- // fMP4 (CMAF) segments: SPS/PPS live once in the init segment, so every media
35
- // segment is small and hardware encoders that do not repeat parameter sets
36
- // (e.g. v4l2m2m) still produce independently-usable segments. The init segment
37
- // is referenced by `#EXT-X-MAP` and fetched once by the player.
38
- const SEGMENT_INIT_FILE_NAME = "init.mp4";
39
- const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.m4s$/;
40
- const CLEANUP_INTERVAL_MS = 30_000;
41
- const DEFAULT_SEGMENT_DURATION_SEC = 4;
42
- // How many segments ahead of the current encode head a missing-segment request
43
- // is allowed to be before we restart ffmpeg at that position (server-side seek).
44
- // Requests within the window are served by waiting for the running encode.
45
- const MAX_LOOKAHEAD_SEGMENTS = 8;
46
- // After a seek-restart, ignore competing restart requests for this long. The
47
- // synthetic VOD playlist lets the player request distant segments in quick
48
- // succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
49
- // between positions, restarting endlessly and producing nothing.
50
- const RESTART_COOLDOWN_MS = 4_000;
51
- // Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
52
- // continuously while it encodes; when it hangs mid-file (alive, but producing
53
- // no output and no stderr a deadlock, e.g. a stalled input read), that output
54
- // stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
55
- // window is being demanded but progress has not advanced for this long, the
56
- // encoder is wedged (observed: the segment 503s forever). Treat it like a seek
57
- // and restart ffmpeg at the demanded segment. Conservative a slow-but-moving
58
- // encode keeps advancing `updatedAt`, so this only fires on a true freeze.
59
- const ENCODER_STALL_MS = 12_000;
60
- // Seek debounce. A far (out-of-window) segment request is a server-side seek.
61
- // Rather than restart ffmpeg on the first one, wait a short quiet period:
62
- // further far requests re-arm it and update the target to the latest index, so
63
- // a scrub that emits a burst of scattered requests (e.g. iOS native HLS firing
64
- // 367,732,369,368,370 seconds apart) collapses to ONE restart at the position
65
- // the player ended on, instead of ping-ponging ffmpeg between positions and
66
- // producing nothing.
67
- const SEEK_SETTLE_MS = 1_200;
68
- // Hard cap on the total settle wait, measured from the first far request of a
69
- // burst, so a still-moving scrubber cannot delay a genuine seek forever.
70
- const SEEK_SETTLE_MAX_MS = 2_500;
71
- // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
72
- // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
73
- // the same session directory. See #startEncodeRun.
74
- const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
75
- // A seek-restart run that exits this fast never did real work it failed at
76
- // the seek/open step itself (container demux error, bad audio frame boundary,
77
- // etc.), not mid-stream. Used to tell a genuine seek failure apart from a
78
- // later, unrelated crash so the circuit breaker below only counts the former.
79
- const SEEK_FAST_FAIL_MS = 2_000;
80
- // Circuit breaker: consecutive fast failures AT THE SAME target before we stop
81
- // auto-retrying and leave the session in its terminal "failed" state (surfaced
82
- // to the client as a clean, retryable error) instead of looping forever. The
83
- // keyframe-snap seek (see #startEncodeRun) already fixes the dominant failure
84
- // mode (an unreliable container-computed seek position); this is a safety net
85
- // for whatever residual case still fails not a second competing "fix" that
86
- // blindly retries the identical command hoping for a different result.
87
- const MAX_SEEK_FAILURES = 3;
88
- // Idle TTL: a session is disposed this long after the last segment/playlist
89
- // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
90
- // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
91
- // also backs the seamless auto-reconnect). ffmpeg stops producing at the
92
- // look-ahead cap when idle, so a lingering session costs retained segments on
93
- // disk, not sustained CPU. Active playback refreshes the timer on every segment
94
- // fetch, so it never expires mid-watch.
95
- const DEFAULT_SESSION_TTL_MS = 10 * 60 * 1000;
96
- const DEFAULT_STARTUP_WAIT_MS = 5_000;
97
- // Realtime budget runtime downswitch (software encoder only). Periodically
98
- // check each active software-transcode session's ffmpeg `speed`; when it stays
99
- // below realtime for a sustained window AND the input is not download-starved
100
- // (so the limit is the encoder, not the torrent), step down one resolution rung
101
- // and restart at the current segment. Conservative so it never thrashes: a long
102
- // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
103
- const BUDGET_CHECK_INTERVAL_MS = 5_000;
104
- // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
105
- // realtime resets the slow window (hysteresis).
106
- const BUDGET_SPEED_SLOW = 0.95;
107
- const BUDGET_SPEED_OK = 1.0;
108
- // Slow must persist this long before a downshift (absorbs warm-up + brief
109
- // complex scenes; the cumulative average won't dip this long unless the host
110
- // genuinely can't keep up).
111
- const BUDGET_SUSTAINED_MS = 15_000;
112
- // After a downshift, wait this long before another (lets the new profile settle
113
- // and a fresh cumulative average build).
114
- const BUDGET_ACTION_COOLDOWN_MS = 30_000;
115
- // Never step down more than this many rungs below the startup choice.
116
- const BUDGET_MAX_DOWNSHIFTS = 3;
117
- // The input counts as "keeping up" when the torrent downloads at least this
118
- // multiple of the source's average byte rate. Below it (and not yet fully
119
- // downloaded), a low speed is download-bound, not CPU-bound do NOT downscale.
120
- const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
121
- // Viewer-link adaptation (adaptive bitrate, part b). The browser reports its
122
- // measured data-channel throughput + buffered seconds every ~10 s; when a
123
- // FRESH report shows the usable link (reported × safety margin) sustainedly
124
- // below the observed produced bitrate AND the viewer's buffer is low, the
125
- // budget loop steps the encode one rung down — same machinery, cooldown and
126
- // floor as the CPU trigger. Manual-quality sessions are inherently exempt
127
- // (their budgetLadder is null).
128
- const LINK_REPORT_FRESH_MS = 30_000;
129
- // Usable share of the reported link (protocol overhead + measurement noise).
130
- const LINK_SAFETY = 0.8;
131
- // Deficit must persist this long before acting (absorbs one slow segment).
132
- const LINK_SLOW_WINDOW_MS = 15_000;
133
- // Only act while the viewer is actually running dry; a comfortable buffer
134
- // (e.g. paused playback filling ahead) suppresses the trigger.
135
- const LINK_LOW_BUFFER_SEC = 10;
136
- // Observed produced bitrate: average over this many recently completed
137
- // segments (the newest file on disk may still be written and is excluded).
138
- const LINK_OBSERVED_SEGMENTS = 5;
139
- const MICROSECONDS_PER_SECOND = 1_000_000;
140
- const PROGRESS_LOG_INTERVAL_MS = 5_000;
141
- // Read segment files in large blocks so the body is delivered to the data
142
- // channel in few, big chunks. On a busy ARM host the in-process WebTorrent
143
- // hashing starves the event loop in bursts, so fewer read iterations means
144
- // far less time lost between chunks while serving the first segments.
145
- const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
146
-
147
- /**
148
- * Resolve after a given number of milliseconds.
149
- *
150
- * @param {number} ms
151
- * @returns {Promise<void>}
152
- */
153
- function delay(ms) {
154
- return new Promise((resolve) => {
155
- setTimeout(resolve, ms);
156
- });
157
- }
158
-
159
- /**
160
- * Wait for a child process to exit, with a hard timeout fallback.
161
- *
162
- * @param {import("node:child_process").ChildProcess} child
163
- * @param {number} [timeoutMs=2000]
164
- * @returns {Promise<void>}
165
- */
166
- function waitForChildExit(child, timeoutMs = 2_000) {
167
- return new Promise((resolve) => {
168
- let settled = false;
169
- const finish = () => {
170
- if (settled) {
171
- return;
172
- }
173
- settled = true;
174
- resolve();
175
- };
176
- child.once("exit", finish);
177
- setTimeout(finish, timeoutMs);
178
- });
179
- }
180
-
181
- /**
182
- * Whether a child process has genuinely exited. `ChildProcess.killed` only
183
- * means `.kill()` was called — the process can stay alive well after that
184
- * (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
185
- * only set once the `exit` event has actually fired, so this is the reliable
186
- * check before treating a directory/file as free for a new process to use.
187
- *
188
- * @param {import("node:child_process").ChildProcess} child
189
- * @returns {boolean}
190
- */
191
- function hasChildExited(child) {
192
- return child.exitCode !== null || child.signalCode !== null;
193
- }
194
-
195
- /**
196
- * Convert a bind-all host address to the loopback address so that
197
- * the HLS input URL is always reachable from the same machine.
198
- *
199
- * @param {string} host
200
- * @returns {string}
201
- */
202
- function toLoopbackHost(host) {
203
- if (host === "0.0.0.0" || host === "::") {
204
- return "127.0.0.1";
205
- }
206
- return host;
207
- }
208
-
209
- /**
210
- * Build the HTTP base URL (scheme + host + port) for the local proxy server.
211
- *
212
- * @param {string} host - Bind host (may be "0.0.0.0" or "::").
213
- * @param {number} port
214
- * @returns {string} e.g. "http://127.0.0.1:9090"
215
- */
216
- function buildHttpBaseUrl(host, port) {
217
- const url = new URL("http://localhost");
218
- url.hostname = toLoopbackHost(host);
219
- url.port = String(port);
220
- return url.origin;
221
- }
222
-
223
- /**
224
- * Return the temporary directory path for a given HLS session.
225
- *
226
- * @param {string} sessionId - UUID of the session.
227
- * @returns {string}
228
- */
229
- function createSessionDirPath(sessionId) {
230
- return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
231
- }
232
-
233
- /**
234
- * Guard against path traversal by validating that a session ID is a UUID.
235
- *
236
- * @param {unknown} value
237
- * @returns {boolean}
238
- */
239
- function isSafeSessionId(value) {
240
- return /^[a-f0-9-]{36}$/i.test(value);
241
- }
242
-
243
- /**
244
- * Guard against path traversal by restricting file names to the known
245
- * playlist and segment patterns produced by ffmpeg.
246
- *
247
- * @param {string} fileName
248
- * @returns {boolean}
249
- */
250
- function isSafeFileName(fileName) {
251
- return (
252
- fileName === PLAYLIST_FILE_NAME ||
253
- fileName === SEGMENT_INIT_FILE_NAME ||
254
- SEGMENT_FILE_NAME_PATTERN.test(fileName)
255
- );
256
- }
257
-
258
- /**
259
- * Extract the zero-based segment index from a segment file name.
260
- * Returns -1 when the name is not a valid segment file.
261
- *
262
- * @param {string} fileName - e.g. "segment-00012.m4s"
263
- * @returns {number}
264
- */
265
- function segmentIndexFromName(fileName) {
266
- const match = /^segment-(\d{5})\.m4s$/.exec(fileName);
267
- if (!match) {
268
- return -1;
269
- }
270
- return Number(match[1]);
271
- }
272
-
273
- /**
274
- * Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
275
- * Returns `null` if the value is absent or malformed.
276
- *
277
- * @param {string | undefined} value
278
- * @returns {number | null}
279
- */
280
- function parseFfmpegTimestamp(value) {
281
- if (!value || typeof value !== "string") {
282
- return null;
283
- }
284
- const parts = value.split(":");
285
- if (parts.length !== 3) {
286
- return null;
287
- }
288
- const hours = Number(parts[0]);
289
- const minutes = Number(parts[1]);
290
- const seconds = Number(parts[2]);
291
- if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
292
- return null;
293
- }
294
- return hours * 3600 + minutes * 60 + seconds;
295
- }
296
-
297
- /**
298
- * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
299
- *
300
- * @param {number} seconds
301
- * @returns {string}
302
- */
303
- function formatSeconds(seconds) {
304
- if (!Number.isFinite(seconds) || seconds < 0) {
305
- return "n/a";
306
- }
307
- const total = Math.floor(seconds);
308
- const hours = Math.floor(total / 3600);
309
- const minutes = Math.floor((total % 3600) / 60);
310
- const rest = total % 60;
311
- return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
312
- }
313
-
314
- /**
315
- * Compute derived progress metrics from raw ffmpeg output values.
316
- *
317
- * When `startPositionSeconds` is provided (seek-restart case), progress is
318
- * computed relative to the remaining duration after the seek point so the
319
- * percent value reflects transcoding of the requested segment, not the whole
320
- * file.
321
- *
322
- * @param {number} processedSeconds - Output timestamp of last encoded frame.
323
- * @param {number | null} totalSeconds - Total duration, or `null` if unknown.
324
- * @param {number} [startPositionSeconds=0] - Seek offset used for this session.
325
- * @returns {{ totalSeconds: number | null, percent: number | null, remainingSeconds: number | null, processedSeconds: number }}
326
- */
327
- function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSeconds = 0) {
328
- const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
329
- const startOffset = Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
330
- ? startPositionSeconds
331
- : 0;
332
- if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
333
- return { totalSeconds: null, percent: null, remainingSeconds: null, processedSeconds: processed };
334
- }
335
- const safeTotal = totalSeconds;
336
- const segmentDuration = Math.max(1, safeTotal - startOffset);
337
- const segmentProcessed = Math.max(0, processed - startOffset);
338
- const percent = Math.max(0, Math.min(100, (segmentProcessed / segmentDuration) * 100));
339
- const remainingSeconds = Math.max(0, safeTotal - processed);
340
- return {
341
- totalSeconds: safeTotal,
342
- percent,
343
- remainingSeconds,
344
- processedSeconds: processed
345
- };
346
- }
347
-
348
- /**
349
- * Run a short ffmpeg probe to extract the total duration AND video resolution
350
- * of a stream from the container header. Both are printed almost immediately
351
- * (before any decoding), so this returns as soon as they are seen; an 8 s
352
- * timeout guards the rest.
353
- *
354
- * @param {string} ffmpegBin - Path to the ffmpeg executable.
355
- * @param {string | URL} inputUrl - URL of the stream to probe.
356
- * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
357
- */
358
- async function probeInputMediaInfo(ffmpegBin, inputUrl) {
359
- return new Promise((resolve) => {
360
- const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
361
- stdio: ["ignore", "ignore", "pipe"],
362
- windowsHide: true
363
- });
364
- let stderr = "";
365
- let settled = false;
366
- const finish = () => {
367
- if (settled) {
368
- return;
369
- }
370
- settled = true;
371
- const dims = parseFfmpegVideoDimensions(stderr);
372
- resolve({
373
- durationSeconds: parseFfmpegDurationSeconds(stderr),
374
- width: dims.width,
375
- height: dims.height,
376
- fps: parseFfmpegVideoFps(stderr),
377
- startTime: parseFfmpegStartTimeSeconds(stderr),
378
- isHdr: parseFfmpegHdr(stderr)
379
- });
380
- };
381
- const timeoutId = setTimeout(() => {
382
- if (!ffmpeg.killed) {
383
- ffmpeg.kill("SIGTERM");
384
- }
385
- finish();
386
- }, 8_000);
387
- ffmpeg.stderr.on("data", (chunk) => {
388
- stderr += String(chunk);
389
- // The header ("Duration:" then the "Video: … WxH" stream line) is printed
390
- // before any decoding. Bail as soon as both are present instead of letting
391
- // `-f null -` decode the whole stream until the 8 s timeout.
392
- const duration = parseFfmpegDurationSeconds(stderr);
393
- const dims = parseFfmpegVideoDimensions(stderr);
394
- if (duration != null && dims.width != null) {
395
- clearTimeout(timeoutId);
396
- if (!ffmpeg.killed) {
397
- ffmpeg.kill("SIGTERM");
398
- }
399
- finish();
400
- }
401
- });
402
- ffmpeg.on("error", () => {
403
- clearTimeout(timeoutId);
404
- finish();
405
- });
406
- ffmpeg.on("exit", () => {
407
- clearTimeout(timeoutId);
408
- finish();
409
- });
410
- });
411
- }
412
-
413
- /**
414
- * Compute the actual output resolution ffmpeg will produce: the target box
415
- * capped to the source (never upscaled), preserving aspect, divisible by 2.
416
- * Mirrors the `scale='min(w,iw)':'min(h,ih)':force_original_aspect_ratio=decrease`
417
- * filter. Returns `null` when the source size is unknown.
418
- *
419
- * @param {number} targetWidth
420
- * @param {number} targetHeight
421
- * @param {number | null} sourceWidth
422
- * @param {number | null} sourceHeight
423
- * @returns {{ w: number, h: number } | null}
424
- */
425
- function computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight) {
426
- const sw = Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : 0;
427
- const sh = Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : 0;
428
- if (!sw || !sh) {
429
- return null;
430
- }
431
- const tw = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : sw;
432
- const th = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : sh;
433
- const scale = Math.min(tw / sw, th / sh, 1);
434
- let w = Math.round(sw * scale);
435
- let h = Math.round(sh * scale);
436
- w -= w % 2;
437
- h -= h % 2;
438
- return { w: Math.max(2, w), h: Math.max(2, h) };
439
- }
440
-
441
- /**
442
- * Resolve the ffprobe binary path from the ffmpeg path (same directory / name).
443
- *
444
- * @param {string} ffmpegBin
445
- * @returns {string}
446
- */
447
- function ffprobeBinFor(ffmpegBin) {
448
- if (typeof ffmpegBin !== "string" || ffmpegBin.length === 0) {
449
- return "ffprobe";
450
- }
451
- if (/ffmpeg(\.exe)?$/i.test(ffmpegBin)) {
452
- return ffmpegBin.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
453
- }
454
- return "ffprobe";
455
- }
456
-
457
- /**
458
- * Probe the source video stream's keyframe timestamps (seconds, in the source
459
- * timeline) via ffprobe packet flags. Used for the video-copy path, where we
460
- * cannot insert keyframes: the synthetic playlist's segment boundaries must
461
- * match the source's real keyframe positions or the player sees gaps on seek.
462
- *
463
- * Time-bounded; returns `null` on failure/timeout (caller falls back to a
464
- * uniform grid). NOTE: reading all video packets streams much of the file from
465
- * the torrent, so for large files this may time out and fall back.
466
- *
467
- * @param {string} ffmpegBin
468
- * @param {string | URL} inputUrl
469
- * @param {number} [timeoutMs]
470
- * @returns {Promise<number[] | null>} Sorted keyframe times, or null.
471
- */
472
- async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000) {
473
- return new Promise((resolve) => {
474
- let proc;
475
- try {
476
- proc = spawn(
477
- ffprobeBinFor(ffmpegBin),
478
- [
479
- "-v", "error",
480
- "-select_streams", "v:0",
481
- "-show_entries", "packet=pts_time,flags",
482
- "-of", "csv=p=0",
483
- String(inputUrl)
484
- ],
485
- { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
486
- );
487
- } catch {
488
- resolve(null);
489
- return;
490
- }
491
- let stdout = "";
492
- let settled = false;
493
- const finish = (value) => {
494
- if (settled) {
495
- return;
496
- }
497
- settled = true;
498
- resolve(value);
499
- };
500
- const timer = setTimeout(() => {
501
- try {
502
- if (!proc.killed) {
503
- proc.kill("SIGTERM");
504
- }
505
- } catch {
506
- // ignore
507
- }
508
- finish(null);
509
- }, timeoutMs);
510
- proc.stdout.on("data", (chunk) => {
511
- stdout += String(chunk);
512
- });
513
- proc.on("error", () => {
514
- clearTimeout(timer);
515
- finish(null);
516
- });
517
- proc.on("exit", (code) => {
518
- clearTimeout(timer);
519
- if (code !== 0) {
520
- finish(null);
521
- return;
522
- }
523
- const times = [];
524
- for (const line of stdout.split("\n")) {
525
- // Each line: "<pts_time>,<flags>" e.g. "12.345000,K__"
526
- const comma = line.indexOf(",");
527
- if (comma < 0) {
528
- continue;
529
- }
530
- const flags = line.slice(comma + 1);
531
- if (!flags.includes("K")) {
532
- continue;
533
- }
534
- const t = Number(line.slice(0, comma));
535
- if (Number.isFinite(t)) {
536
- times.push(t);
537
- }
538
- }
539
- times.sort((a, b) => a - b);
540
- finish(times.length > 0 ? times : null);
541
- });
542
- });
543
- }
544
-
545
- /**
546
- * Compute segment START times (a 0-based timeline) for a session.
547
- *
548
- * - Re-encoded video: a uniform grid (0, segDur, 2·segDur, ) — ffmpeg's fixed
549
- * GOP makes the real cuts land exactly here.
550
- * - Copied video: the source's real keyframes, normalized to 0 (start time
551
- * subtracted) and greedily grouped to ≥ segDur — these are exactly where
552
- * `-hls_time segDur` cuts a copied stream, so the playlist matches reality.
553
- *
554
- * The returned array starts at 0 and ends at `durationSeconds` (so segment i
555
- * spans `[boundaries[i], boundaries[i+1])`). Falls back to a uniform grid when
556
- * keyframes are unavailable.
557
- *
558
- * @param {{ transcodeVideo: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
559
- * @returns {number[]}
560
- */
561
- function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, keyframeTimes, startTime }) {
562
- const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
563
- const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
564
- const uniform = () => {
565
- const boundaries = [];
566
- for (let t = 0; t < total - 0.001; t += step) {
567
- boundaries.push(Number(t.toFixed(6)));
568
- }
569
- boundaries.push(total);
570
- return boundaries;
571
- };
572
- if (transcodeVideo || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
573
- return uniform();
574
- }
575
- const base = Number.isFinite(startTime) ? startTime : 0;
576
- const norm = keyframeTimes
577
- .map((t) => t - base)
578
- .filter((t) => t >= -0.001 && t < total - 0.05)
579
- .sort((a, b) => a - b);
580
- const boundaries = [0];
581
- for (const kf of norm) {
582
- if (kf >= boundaries[boundaries.length - 1] + step - 0.05) {
583
- boundaries.push(Number(kf.toFixed(6)));
584
- }
585
- }
586
- boundaries.push(total);
587
- // Guard against a degenerate probe (e.g. a single keyframe) — fall back.
588
- return boundaries.length >= 2 ? boundaries : uniform();
589
- }
590
-
591
- /**
592
- * The largest keyframe time that does not exceed `target`, from a SORTED
593
- * (ascending) array of keyframe times such as {@link probeVideoKeyframeTimes}
594
- * returns. Null when `target` is before the first keyframe or the array is
595
- * empty the caller then falls back to its unsnapped target.
596
- *
597
- * @param {number[]} keyframeTimes - Sorted ascending.
598
- * @param {number} target
599
- * @returns {number | null}
600
- */
601
- function nearestKeyframeAtOrBefore(keyframeTimes, target) {
602
- let result = null;
603
- for (const time of keyframeTimes) {
604
- if (time > target) {
605
- break;
606
- }
607
- result = time;
608
- }
609
- return result;
610
- }
611
-
612
- function isWarmupTimeoutError(error) {
613
- if (!(error instanceof Error)) {
614
- return false;
615
- }
616
- return error.message === "HLS playlist is still warming up.";
617
- }
618
-
619
- function normalizeLogFileName(fileName, fileIndex) {
620
- const fallback = `file#${fileIndex}`;
621
- if (typeof fileName !== "string") {
622
- return fallback;
623
- }
624
- const value = fileName.trim();
625
- if (value.length === 0) {
626
- return fallback;
627
- }
628
- return value;
629
- }
630
-
631
- /**
632
- * @typedef {Object} HlsSessionManagerOptions
633
- * @property {boolean} enabled - Whether HLS transcoding is enabled.
634
- * @property {string} ffmpegBin - Path to the ffmpeg executable.
635
- * @property {string} localBindHost - Host the proxy HTTP server is bound to.
636
- * @property {number} localPort - Port the proxy HTTP server is listening on.
637
- * @property {number} [segmentDurationSec] - HLS segment length in seconds.
638
- * @property {number} [sessionTtlMs] - Session idle TTL in milliseconds.
639
- * @property {number} [startupWaitMs] - Max time to wait for the first playlist file.
640
- */
641
-
642
- /**
643
- * @typedef {Object} HlsSession
644
- * @property {string} id - UUID of the session.
645
- * @property {string} sourceMapKey - Cache key combining source + transcode settings.
646
- * @property {string} fileName - Display name of the file being transcoded.
647
- * @property {string} dirPath - Temp directory containing HLS output.
648
- * @property {"starting" | "ready" | "failed" | "disposed"} state
649
- * @property {number} startedAt - Unix ms timestamp when the session was created.
650
- * @property {number} lastAccessedAt - Unix ms timestamp of the last consumer access.
651
- * @property {import("node:child_process").ChildProcess} ffmpeg
652
- * @property {string} lastError
653
- * @property {Set<string>} consumers - Consumer IDs currently using this session.
654
- * @property {object} progress - Live progress metrics updated from ffmpeg stdout.
655
- * @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
656
- * lets a call that awaited the previous ffmpeg's exit detect it was superseded
657
- * by a newer restart request and abort instead of spawning a second process.
658
- * @property {number[] | null} keyframeTimes - Real source keyframe times
659
- * (sorted seconds), or null when the probe failed/timed out. Used to snap a
660
- * source seek onto a known-valid position (see #startEncodeRun).
661
- * @property {number} seekFailureTarget - Segment index of the last fast seek
662
- * failure, for the consecutive-failure circuit breaker (see MAX_SEEK_FAILURES).
663
- * @property {number} seekFailureCount - Consecutive fast failures at seekFailureTarget.
664
- */
665
-
666
- /**
667
- * Manages HLS transcode sessions backed by ffmpeg child processes.
668
- *
669
- * One session is created per unique (source, fileIndex, transcode settings)
670
- * combination. Sessions are reused across consumers and are automatically
671
- * expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
672
- */
673
- export class HlsSessionManager {
674
- /**
675
- * @param {HlsSessionManagerOptions} options
676
- */
677
- constructor({
678
- enabled,
679
- ffmpegBin,
680
- localBindHost,
681
- localPort,
682
- segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
683
- sessionTtlMs = DEFAULT_SESSION_TTL_MS,
684
- startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
685
- videoEncoder = null,
686
- softwarePresetBenchmark = null,
687
- getSourceStats = null,
688
- tonemapSupported = false,
689
- getCachedMediaInfo = null
690
- }) {
691
- this.enabled = Boolean(enabled);
692
- this.ffmpegBin = ffmpegBin;
693
- // Optional accessor for media info the playback planner already probed for
694
- // (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
695
- this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
696
- // Optional async accessor for a source's live download stats, used by the
697
- // realtime budget to tell a CPU limit from a download-starved input:
698
- // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
699
- this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
700
- // Detected H.264 encoder descriptor (hardware or software). Defaults to
701
- // software libx264 when no detection result is supplied. May be downgraded
702
- // to software at runtime if a hardware encode fails.
703
- this.videoEncoder = videoEncoder ?? softwareDescriptor();
704
- // Per-preset software encode throughput (pixels/sec) measured at startup,
705
- // used to pick the best preset per stream. Null when unavailable (hardware
706
- // encoder, or benchmark skipped/failed).
707
- this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
708
- // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
709
- // Gates the tonemap chain for HDR sources on the software path.
710
- this.tonemapSupported = Boolean(tonemapSupported);
711
- this.segmentDurationSec = segmentDurationSec;
712
- this.sessionTtlMs = sessionTtlMs;
713
- this.startupWaitMs = startupWaitMs;
714
- this.localBaseUrl = buildHttpBaseUrl(localBindHost, localPort);
715
- this.sessionsById = new Map();
716
- this.sessionIdBySource = new Map();
717
- this.cleanupTimer = setInterval(() => {
718
- void this.cleanupExpired();
719
- }, CLEANUP_INTERVAL_MS);
720
- this.cleanupTimer.unref();
721
- // Realtime-budget monitor: only meaningful for the software encoder with a
722
- // benchmark (the only path that can pick/step resolution). Cheap no-op scan
723
- // otherwise.
724
- this.budgetTimer = setInterval(() => {
725
- void this.#enforceRealtimeBudget();
726
- }, BUDGET_CHECK_INTERVAL_MS);
727
- this.budgetTimer.unref();
728
- }
729
-
730
- /**
731
- * Return an existing HLS session for the given source/settings, or create
732
- * one by spawning a new ffmpeg process.
733
- *
734
- * Throws with `error.code === "TRANSCODE_DISABLED"` when transcoding is
735
- * disabled on this proxy instance.
736
- *
737
- * @param {object} options
738
- * @param {string} options.sourceKey - Registry source key.
739
- * @param {number} options.fileIndex - Zero-based file index in the torrent.
740
- * @param {boolean} [options.transcodeVideo=false]
741
- * @param {boolean} [options.transcodeAudio=false]
742
- * @param {string} [options.consumerId=""] - Caller ID for reference counting.
743
- * @param {string} [options.fileName=""] - Display name for log output.
744
- * @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
745
- * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
746
- * @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
747
- * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
748
- * @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
749
- * @returns {Promise<HlsSession>}
750
- */
751
- async createOrGetSession({
752
- sourceKey,
753
- fileIndex,
754
- transcodeVideo = false,
755
- transcodeAudio = false,
756
- consumerId = "",
757
- fileName = "",
758
- targetWidth = 0,
759
- targetHeight = 0,
760
- startPositionSeconds = 0,
761
- audioTrackIndex = 0,
762
- manualQuality = false
763
- }) {
764
- if (!this.enabled) {
765
- const error = new Error("Audio transcoding is disabled on this proxy.");
766
- error.code = "TRANSCODE_DISABLED";
767
- throw error;
768
- }
769
-
770
- const normalizedTargetWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0;
771
- const normalizedTargetHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0;
772
- // Round seek position to the nearest 10 s so that two consumers seeking
773
- // to similar positions can share the same ffmpeg session.
774
- const normalizedStartPosition =
775
- Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
776
- ? Math.round(startPositionSeconds / 10) * 10
777
- : 0;
778
- const normalizedAudioTrack =
779
- Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
780
- const forceManualQuality = manualQuality === true && transcodeVideo;
781
- const sourceMapKey = [
782
- sourceKey,
783
- String(fileIndex),
784
- transcodeVideo ? "video" : "audio",
785
- transcodeAudio ? "a1" : "a0",
786
- `t${normalizedAudioTrack}`,
787
- String(normalizedTargetWidth),
788
- String(normalizedTargetHeight),
789
- forceManualQuality ? "q-manual" : "q-auto",
790
- String(normalizedStartPosition)
791
- ].join(":");
792
- const existingId = this.sessionIdBySource.get(sourceMapKey);
793
- if (existingId) {
794
- const existing = this.sessionsById.get(existingId);
795
- if (existing && existing.state !== "failed") {
796
- existing.fileName = normalizeLogFileName(fileName, fileIndex);
797
- if (consumerId) {
798
- existing.consumers.add(consumerId);
799
- }
800
- existing.lastAccessedAt = Date.now();
801
- try {
802
- await this.waitUntilReady(existing);
803
- } catch (error) {
804
- if (!isWarmupTimeoutError(error)) {
805
- throw error;
806
- }
807
- // Keep session reusable while ffmpeg is still warming up.
808
- }
809
- return existing;
810
- }
811
- }
812
-
813
- const sessionId = randomUUID();
814
- const createEntryMs = Date.now();
815
- const sessionDir = createSessionDirPath(sessionId);
816
- await mkdir(sessionDir, { recursive: true });
817
- const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
818
- inputUrl.searchParams.set("sourceKey", sourceKey);
819
- inputUrl.searchParams.set("fileIndex", String(fileIndex));
820
-
821
- // Media info (duration/resolution/fps/startTime/HDR) up front, so we can
822
- // serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
823
- // duration and a fully seekable timeline before a single segment exists.
824
- // Reuse the planner's probe when it is available and complete — the plan
825
- // request just ran the same ffmpeg scan over the same input. Fall back to
826
- // a fresh probe otherwise (proxy restarted between plan and session, or a
827
- // critical field is missing).
828
- const mediaInfoStartMs = Date.now();
829
- const cachedMediaInfo = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
830
- const cachedUsable =
831
- cachedMediaInfo &&
832
- Number.isFinite(cachedMediaInfo.durationSeconds) &&
833
- cachedMediaInfo.durationSeconds > 0 &&
834
- Number.isFinite(cachedMediaInfo.width) &&
835
- cachedMediaInfo.width > 0 &&
836
- Number.isFinite(cachedMediaInfo.height) &&
837
- cachedMediaInfo.height > 0;
838
- const mediaInfo = cachedUsable
839
- ? cachedMediaInfo
840
- : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
841
- const mediaInfoMs = Date.now() - mediaInfoStartMs;
842
- const mediaInfoSource = cachedUsable ? "cached" : "probed";
843
- const durationSeconds = mediaInfo.durationSeconds;
844
- const sourceWidth = mediaInfo.width;
845
- const sourceHeight = mediaInfo.height;
846
- const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
847
- // Tone-map an HDR source to SDR only when re-encoding video on the software
848
- // path and this ffmpeg has the filters. Hardware encoders keep their own
849
- // (untone-mapped) path for now; when unavailable, HDR falls back to a plain
850
- // 8-bit convert (washed-out but playable).
851
- const applyTonemap =
852
- transcodeVideo === true &&
853
- mediaInfo.isHdr === true &&
854
- this.tonemapSupported &&
855
- this.videoEncoder?.kind === "software";
856
- // Output frame rate inherited from the source (integer, capped) so 25/30
857
- // fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
858
- // relationship exact; time-based-keyframe encoders just use it as the rate.
859
- const outputFps = chooseOutputFps(mediaInfo.fps);
860
- const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
861
- const logName = normalizeLogFileName(fileName, fileIndex);
862
- if (!hasDuration) {
863
- logger.warn(
864
- `transcode ${sessionId}: could not probe duration; falling back to ` +
865
- `ffmpeg-managed (growing) playlist for "${logName}"`
866
- );
867
- }
868
-
869
- // For the video-copy path we cannot insert keyframes, so the playlist's
870
- // segment boundaries must match the source's real keyframes (otherwise the
871
- // player sees gaps on seek). Re-encoded video uses a uniform grid for
872
- // segment boundaries instead (its fixed GOP makes the cuts land there —
873
- // computeSegmentBoundaries ignores keyframeTimes when transcodeVideo).
874
- //
875
- // But the probe is ALSO used for something both branches need: choosing a
876
- // SOURCE seek position ffmpeg can actually land on. `-ss` before `-i` trusts
877
- // the container's own on-the-fly seek/index, which for some containers
878
- // (observed: AVI with VBR MP3 audio) can point at a position with no valid
879
- // frame boundary at all ffmpeg then fails outright ("Seek failed" /
880
- // "Header missing"), not just imprecisely. Snapping the seek to the nearest
881
- // KNOWN real keyframe (see #startEncodeRun) avoids that. So probe for both
882
- // branches; on failure both fall back to their current behaviour (uniform
883
- // grid for boundaries, raw target for seeking) — no regression.
884
- let keyframeTimes = null;
885
- let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
886
- if (hasDuration && !transcodeVideo) {
887
- // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
888
- // boundaries (the playlist itself), so this MUST block session creation —
889
- // an incorrect playlist is worse than a slower start. Short timeout: mp4
890
- // keyframes come from the moov index (fast); containers that force a full
891
- // packet scan time out and fall back to a uniform grid, so this never adds
892
- // more than ~6 s to session start.
893
- const keyframeStartMs = Date.now();
894
- keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
895
- keyframeMs = Date.now() - keyframeStartMs;
896
- if (!keyframeTimes) {
897
- logger.warn(
898
- `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
899
- `for "${logName}" (seek precision may be reduced)`
900
- );
901
- }
902
- } else if (hasDuration && transcodeVideo) {
903
- // Re-encode path: keyframeTimes are ONLY used to snap a LATER seek (see
904
- // #startEncodeRun) segment boundaries stay on the uniform grid either
905
- // way. So this does NOT need to block session creation / the first
906
- // segment's start. Run it in the background with a FULL budget instead of
907
- // the 6 s cap: AVI-class containers need a full packet scan, which 6 s can
908
- // never afford without delaying playback startthat starved budget is
909
- // exactly why the probe kept missing on the container where the seek bug
910
- // was field-diagnosed. #startEncodeRun reads session.keyframeTimes fresh
911
- // on every call, so a seek that happens AFTER this finishes picks it up
912
- // automatically; one that happens before falls back to the existing
913
- // circuit breaker as a safety net (no regression either way).
914
- keyframeMs = -2;
915
- const backgroundStartedAt = Date.now();
916
- void probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 25_000).then((times) => {
917
- const liveSession = this.sessionsById.get(sessionId);
918
- if (!liveSession || liveSession.state === "disposed") {
919
- return; // Session gone before the probe finished — nothing to update.
920
- }
921
- liveSession.keyframeTimes = times;
922
- const elapsedMs = Date.now() - backgroundStartedAt;
923
- logger.info(
924
- times
925
- ? `transcode ${sessionId}: background keyframe probe found ${times.length} keyframes ` +
926
- `(${elapsedMs}ms) for "${logName}" — later seeks will snap to them`
927
- : `transcode ${sessionId}: background keyframe probe unavailable (${elapsedMs}ms) for "${logName}" ` +
928
- `— seeks keep using the raw target (falls back to the circuit breaker on failure)`
929
- );
930
- });
931
- }
932
- logger.info(
933
- `cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
934
- `keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
935
- `create-total=${Date.now() - createEntryMs}ms`
936
- );
937
- const segmentBoundaries = hasDuration
938
- ? computeSegmentBoundaries({
939
- transcodeVideo,
940
- durationSeconds,
941
- segDur: this.segmentDurationSec,
942
- keyframeTimes,
943
- startTime: sourceStartTime
944
- })
945
- : [];
946
- const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
947
- const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
948
-
949
- // Realtime budget (software encoder): pick the output resolution + libx264
950
- // preset this host can encode faster than realtime. On a weak host this
951
- // downscales below the client target (the orientation-independent ceiling)
952
- // instead of dropping into sub-realtime playback. Null for hardware
953
- // encoders or when the source size / benchmark is unavailable — the encode
954
- // then keeps the client target box and buildVideoArgs's default preset.
955
- //
956
- // Manual quality bypasses the budget entirely: the user forced a specific
957
- // resolution, so encode exactly that box (capped to source by the scale
958
- // filter) with the default preset, and the runtime downswitch is skipped
959
- // for the session (budgetLadder stays null).
960
- const encodeBudget = forceManualQuality
961
- ? null
962
- : this.#chooseEncodeBudget({
963
- transcodeVideo,
964
- targetWidth: normalizedTargetWidth,
965
- targetHeight: normalizedTargetHeight,
966
- sourceWidth,
967
- sourceHeight,
968
- outputFps
969
- });
970
- const softwarePreset = encodeBudget?.preset ?? null;
971
- // Effective encode box: the budget's downscaled resolution when applied,
972
- // otherwise the client target (0 = keep source, handled by buildVideoArgs).
973
- const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
974
- const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
975
-
976
- const session = {
977
- id: sessionId,
978
- sourceMapKey,
979
- fileName: logName,
980
- dirPath: sessionDir,
981
- state: "starting",
982
- startedAt: Date.now(),
983
- lastAccessedAt: Date.now(),
984
- ffmpeg: null,
985
- encodeRunGeneration: 0,
986
- lastError: "",
987
- // Cold-start timing: entry timestamp + a once-guard so the first servable
988
- // segment logs its latency exactly once.
989
- createEntryMs,
990
- firstSegmentLogged: false,
991
- consumers: new Set(consumerId ? [consumerId] : []),
992
- // Transcode parameters retained so the encode run can be restarted at an
993
- // arbitrary segment when the player seeks (server-side seeking).
994
- sourceKey,
995
- fileIndex,
996
- transcodeVideo,
997
- transcodeAudio,
998
- audioTrackIndex: normalizedAudioTrack,
999
- outputFps,
1000
- // Client-requested target box (the orientation-independent ceiling). Kept
1001
- // for the session key and reference; the actual encode uses encodeWidth/
1002
- // encodeHeight, which the realtime budget may have downscaled below this.
1003
- targetWidth: normalizedTargetWidth,
1004
- targetHeight: normalizedTargetHeight,
1005
- // Effective encode resolution handed to ffmpeg (budget-selected on weak
1006
- // software hosts, else the client target). 0 = keep source.
1007
- encodeWidth,
1008
- encodeHeight,
1009
- // Whether to insert the HDR→SDR tone-map chain (software path only).
1010
- applyTonemap,
1011
- // Realtime-budget runtime state (software encoder only). The ladder is the
1012
- // resolution rungs from the ceiling down; rungIndex is the current rung.
1013
- // The monitor steps rungIndex down when the encoder is sustainedly
1014
- // CPU-bound and restarts ffmpeg at the current segment.
1015
- budgetLadder: encodeBudget?.ladder ?? null,
1016
- budgetRungIndex: Number.isInteger(encodeBudget?.rungIndex) ? encodeBudget.rungIndex : 0,
1017
- budgetDownshifts: 0,
1018
- budgetSlowSince: 0,
1019
- budgetLastActionAt: 0,
1020
- // Latest viewer link report ({ linkMbps, bufferedAheadSec, at }) and the
1021
- // link-deficit slow window (mirrors budgetSlowSince for the CPU path).
1022
- netReport: null,
1023
- linkSlowSince: 0,
1024
- sourceWidth,
1025
- sourceHeight,
1026
- // Container start time (seconds); subtracted on the copy path so the
1027
- // output timeline is 0-based even when the source starts at e.g. 0.1 s.
1028
- sourceStartTime,
1029
- // Chosen libx264 preset for this stream (software only), or null.
1030
- softwarePreset,
1031
- inputUrl: inputUrl.toString(),
1032
- // VOD playlist bookkeeping.
1033
- useSyntheticPlaylist: hasDuration,
1034
- totalDurationSeconds: hasDuration ? durationSeconds : null,
1035
- // Segment start times (0-based). Uniform grid for re-encoded video; real
1036
- // keyframe positions for copied video. Drives the playlist and seeking.
1037
- segmentBoundaries,
1038
- segmentCount,
1039
- // Real source keyframe times (sorted seconds), or null when the probe
1040
- // failed/timed out. Used by #startEncodeRun to snap a source seek onto a
1041
- // KNOWN valid position instead of trusting the container's own on-the-fly
1042
- // seek at an arbitrary target — see the probe call above for why.
1043
- keyframeTimes,
1044
- playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries) : "",
1045
- // Segment index the current ffmpeg run started producing from.
1046
- encodeStartIndex: 0,
1047
- // Guards against repeatedly restarting to the same seek position.
1048
- pendingRestartIndex: -1,
1049
- // Timestamp of the last encode (re)start, for the restart cooldown.
1050
- lastRestartAt: 0,
1051
- // Seek debounce: pending settle timer, the far segment index to restart
1052
- // at once the burst settles, and the timestamp of the burst's first far
1053
- // request (for the SEEK_SETTLE_MAX_MS cap).
1054
- seekSettleTimer: null,
1055
- seekTarget: null,
1056
- seekFirstFarAt: 0,
1057
- // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1058
- // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
1059
- // survives past the fast-fail window. See the exit handler in
1060
- // #wireEncodeProcess and MAX_SEEK_FAILURES.
1061
- seekFailureTarget: -1,
1062
- seekFailureCount: 0,
1063
- progress: {
1064
- state: "starting",
1065
- processedSeconds: 0,
1066
- startPositionSeconds: 0,
1067
- totalSeconds: hasDuration ? durationSeconds : null,
1068
- percent: null,
1069
- remainingSeconds: hasDuration ? durationSeconds : null,
1070
- speed: "",
1071
- updatedAt: Date.now(),
1072
- lastLoggedAt: 0
1073
- }
1074
- };
1075
- this.sessionsById.set(sessionId, session);
1076
- this.sessionIdBySource.set(sourceMapKey, sessionId);
1077
-
1078
- logger.info(
1079
- `transcode ${sessionId} start "${logName}" ` +
1080
- `video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
1081
- `audio=${transcodeAudio ? "aac" : "copy"} ` +
1082
- // Branch tag for log correlation: A = video re-encode (fixed GOP, grid
1083
- // aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
1084
- `branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
1085
- `seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
1086
- `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
1087
- // Effective encode resolution: budget-on (auto downscale from the
1088
- // ceiling), manual (user-forced, budget off), or unset (keep source).
1089
- `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
1090
- `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
1091
- // HDR source and whether the tone-map chain was applied (vs washed-out
1092
- // fallback when the filters are missing or on a hardware encoder).
1093
- `${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
1094
- `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
1095
- `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
1096
- );
1097
-
1098
- await this.#startEncodeRun(session, 0);
1099
-
1100
- try {
1101
- await this.waitUntilReady(session);
1102
- return session;
1103
- } catch (error) {
1104
- if (session.state === "failed") {
1105
- await this.disposeSession(session.id);
1106
- throw error;
1107
- }
1108
- // Do not fail session creation on warmup timeout; the synthetic playlist
1109
- // is already available and segments appear as ffmpeg produces them.
1110
- return session;
1111
- }
1112
- }
1113
-
1114
- /**
1115
- * Build a complete VOD HLS playlist for the full media duration.
1116
- *
1117
- * The playlist lists every segment up-front and is terminated with
1118
- * `#EXT-X-ENDLIST`, so the player knows the total duration and can seek to
1119
- * any position immediately even before the corresponding segment has been
1120
- * transcoded. Segments are produced on demand (see {@link getFileStream}).
1121
- *
1122
- * @param {number[]} boundaries - Segment start times (0-based); segment i
1123
- * spans `[boundaries[i], boundaries[i+1])`.
1124
- * @returns {string}
1125
- */
1126
- #buildVodPlaylist(boundaries) {
1127
- const count = Math.max(0, boundaries.length - 1);
1128
- let maxDuration = 0;
1129
- for (let index = 0; index < count; index += 1) {
1130
- const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
1131
- if (duration > maxDuration) {
1132
- maxDuration = duration;
1133
- }
1134
- }
1135
- const lines = [
1136
- "#EXTM3U",
1137
- // Version 7: required for fMP4 media segments + `#EXT-X-MAP`.
1138
- "#EXT-X-VERSION:7",
1139
- `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
1140
- "#EXT-X-MEDIA-SEQUENCE:0",
1141
- "#EXT-X-PLAYLIST-TYPE:VOD",
1142
- "#EXT-X-INDEPENDENT-SEGMENTS",
1143
- // The fMP4 init segment (codec config / SPS/PPS). Fetched once; applies to
1144
- // every media segment below.
1145
- `#EXT-X-MAP:URI="${SEGMENT_INIT_FILE_NAME}"`
1146
- ];
1147
- for (let index = 0; index < count; index += 1) {
1148
- const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
1149
- lines.push(`#EXTINF:${duration.toFixed(6)},`);
1150
- lines.push(`segment-${String(index).padStart(5, "0")}.m4s`);
1151
- }
1152
- lines.push("#EXT-X-ENDLIST");
1153
- return `${lines.join("\n")}\n`;
1154
- }
1155
-
1156
- /**
1157
- * Start time (seconds, 0-based) of segment `index`, from the session's
1158
- * boundary table. Clamped to valid range.
1159
- *
1160
- * @param {HlsSession} session
1161
- * @param {number} index
1162
- * @returns {number}
1163
- */
1164
- #segmentStartTime(session, index) {
1165
- const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
1166
- if (boundaries.length === 0) {
1167
- return index * this.segmentDurationSec;
1168
- }
1169
- const clamped = Math.max(0, Math.min(index, boundaries.length - 1));
1170
- return boundaries[clamped];
1171
- }
1172
-
1173
- /**
1174
- * Segment index whose span contains time `t` (0-based), via the boundary
1175
- * table.
1176
- *
1177
- * @param {HlsSession} session
1178
- * @param {number} t
1179
- * @returns {number}
1180
- */
1181
- #segmentIndexForTime(session, t) {
1182
- const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
1183
- if (boundaries.length < 2) {
1184
- return Math.max(0, Math.floor(t / this.segmentDurationSec));
1185
- }
1186
- // boundaries is sorted ascending; find the last boundary <= t.
1187
- let lo = 0;
1188
- let hi = boundaries.length - 1;
1189
- let result = 0;
1190
- while (lo <= hi) {
1191
- const mid = (lo + hi) >> 1;
1192
- if (boundaries[mid] <= t) {
1193
- result = mid;
1194
- lo = mid + 1;
1195
- } else {
1196
- hi = mid - 1;
1197
- }
1198
- }
1199
- return Math.min(result, boundaries.length - 2);
1200
- }
1201
-
1202
- /**
1203
- * Realtime budget (software encoder only): choose the output resolution AND
1204
- * libx264 preset this host can encode faster than realtime, from the startup
1205
- * benchmark. The ceiling is the client-requested box capped to the source
1206
- * (never upscaled); the budget picks the highest resolution rung at or below
1207
- * that ceiling that clears realtime × margin, then the best preset at that
1208
- * resolution. On a weak host this downscales below the client target instead
1209
- * of dropping into sub-realtime playback. Returns null when not applicable
1210
- * (no video transcode, hardware encoder, or missing benchmark/source size)
1211
- * the encode then keeps the ceiling resolution and the default preset.
1212
- *
1213
- * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
1214
- * @returns {{ width: number, height: number, preset: string } | null}
1215
- */
1216
- #chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
1217
- if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
1218
- return null;
1219
- }
1220
- const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
1221
- if (!ceiling) {
1222
- return null;
1223
- }
1224
- return chooseSoftwareEncodeSettings(this.softwarePresetBenchmark, { width: ceiling.w, height: ceiling.h }, outputFps);
1225
- }
1226
-
1227
- /**
1228
- * Parse ffmpeg's `speed` progress value (e.g. "0.903x", "1.6x", "N/A") into a
1229
- * number. Returns null when it cannot be parsed (no data yet).
1230
- *
1231
- * @param {string} value
1232
- * @returns {number | null}
1233
- */
1234
- #parseSpeed(value) {
1235
- if (typeof value !== "string" || value.length === 0) {
1236
- return null;
1237
- }
1238
- const numeric = Number.parseFloat(value);
1239
- return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
1240
- }
1241
-
1242
- /**
1243
- * Realtime budget monitor (software encoder only). For each active
1244
- * software-transcode session, watch the encoder's cumulative `speed`: when it
1245
- * stays below realtime for a sustained window AND the input is not
1246
- * download-starved (so the limit is the encoder, not the torrent), step the
1247
- * resolution one rung down the ladder and restart the encode at the current
1248
- * segment. Conservative: sustained window, post-action cooldown, a step cap,
1249
- * and a resolution floor (the last ladder rung). No upswitch in v1.
1250
- *
1251
- * @returns {Promise<void>}
1252
- */
1253
- /**
1254
- * Record the latest viewer link report for a session (adaptive bitrate).
1255
- * Returns false for an unknown/disposed session.
1256
- *
1257
- * @param {string} sessionId
1258
- * @param {{ linkMbps: number, bufferedAheadSec: number }} report
1259
- * @returns {boolean}
1260
- */
1261
- recordNetReport(sessionId, { linkMbps, bufferedAheadSec }) {
1262
- const session = this.sessionsById.get(sessionId);
1263
- if (!session || session.state === "disposed") {
1264
- return false;
1265
- }
1266
- session.netReport = { linkMbps, bufferedAheadSec, at: Date.now() };
1267
- return true;
1268
- }
1269
-
1270
- /**
1271
- * Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
1272
- * segment files (the newest file may still be being written and is
1273
- * excluded). Transcode sessions only — their segment grid is uniform, so
1274
- * bytes / (count × segDur) is exact. Returns null when there is not enough
1275
- * material to measure.
1276
- *
1277
- * @param {HlsSession} session
1278
- * @returns {Promise<number | null>}
1279
- */
1280
- async #observedStreamMbps(session) {
1281
- let names;
1282
- try {
1283
- names = await readdir(session.dirPath);
1284
- } catch {
1285
- return null;
1286
- }
1287
- const indices = [];
1288
- for (const name of names) {
1289
- const match = /^segment-(\d{5})\.m4s$/.exec(name);
1290
- if (match) {
1291
- indices.push(parseInt(match[1], 10));
1292
- }
1293
- }
1294
- if (indices.length < 3) {
1295
- return null; // need ≥2 completed segments after dropping the newest
1296
- }
1297
- indices.sort((a, b) => a - b);
1298
- const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
1299
- let bytes = 0;
1300
- try {
1301
- for (const index of completed) {
1302
- const st = await stat(path.join(session.dirPath, `segment-${String(index).padStart(5, "0")}.m4s`));
1303
- bytes += st.size;
1304
- }
1305
- } catch {
1306
- return null; // a segment vanished mid-measure (seek-restart cleanup)
1307
- }
1308
- return (bytes * 8) / (completed.length * this.segmentDurationSec) / 1e6;
1309
- }
1310
-
1311
- /**
1312
- * Viewer-link deficit check for one session (adaptive bitrate, part b).
1313
- * Mirrors the CPU slow-window pattern; shares the action cooldown and the
1314
- * downshift machinery. Returns true when a downshift was applied this tick.
1315
- *
1316
- * @param {HlsSession} session
1317
- * @param {number} now
1318
- * @returns {Promise<boolean>}
1319
- */
1320
- async #checkLinkBudget(session, now) {
1321
- const report = session.netReport;
1322
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
1323
- session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
1324
- return false;
1325
- }
1326
- if (report.bufferedAheadSec >= LINK_LOW_BUFFER_SEC) {
1327
- session.linkSlowSince = 0; // viewer is comfortable — nothing to fix
1328
- return false;
1329
- }
1330
- const observed = await this.#observedStreamMbps(session);
1331
- if (observed === null) {
1332
- return false; // not enough produced material to compare against
1333
- }
1334
- if (report.linkMbps * LINK_SAFETY >= observed) {
1335
- session.linkSlowSince = 0; // link keeps up
1336
- return false;
1337
- }
1338
- if (session.linkSlowSince === 0) {
1339
- session.linkSlowSince = now;
1340
- return false;
1341
- }
1342
- if (now - session.linkSlowSince < LINK_SLOW_WINDOW_MS) {
1343
- return false; // not sustained yet
1344
- }
1345
- if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1346
- return false; // let the previous action settle
1347
- }
1348
- await this.#applyBudgetDownshift(
1349
- session,
1350
- `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
1351
- "link"
1352
- );
1353
- session.linkSlowSince = 0;
1354
- return true;
1355
- }
1356
-
1357
- async #enforceRealtimeBudget() {
1358
- if (this.videoEncoder?.kind !== "software") {
1359
- return;
1360
- }
1361
- const now = Date.now();
1362
- for (const session of this.sessionsById.values()) {
1363
- if (
1364
- !session ||
1365
- session.state === "disposed" ||
1366
- session.state === "failed" ||
1367
- !session.transcodeVideo ||
1368
- !Array.isArray(session.budgetLadder) ||
1369
- session.budgetLadder.length < 2
1370
- ) {
1371
- continue;
1372
- }
1373
- // Already at the floor or out of steps — nothing more to give.
1374
- if (
1375
- session.budgetRungIndex >= session.budgetLadder.length - 1 ||
1376
- session.budgetDownshifts >= BUDGET_MAX_DOWNSHIFTS
1377
- ) {
1378
- continue;
1379
- }
1380
- // Viewer-link deficit first (adaptive bitrate): independent of encoder
1381
- // speed a thin cellular link starves even a faster-than-realtime
1382
- // encode. When it acts, skip the CPU check this tick (shared cooldown
1383
- // guards double-firing anyway).
1384
- if (await this.#checkLinkBudget(session, now)) {
1385
- continue;
1386
- }
1387
- const speed = this.#parseSpeed(session.progress?.speed);
1388
- if (speed === null) {
1389
- continue; // no measurement yet
1390
- }
1391
- if (speed >= BUDGET_SPEED_OK) {
1392
- session.budgetSlowSince = 0; // recovered — reset the slow window
1393
- continue;
1394
- }
1395
- if (speed >= BUDGET_SPEED_SLOW) {
1396
- continue; // in the hysteresis band; neither slow nor ok
1397
- }
1398
- // speed < BUDGET_SPEED_SLOW track how long it has been slow.
1399
- if (session.budgetSlowSince === 0) {
1400
- session.budgetSlowSince = now;
1401
- continue;
1402
- }
1403
- if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
1404
- continue; // not sustained yet
1405
- }
1406
- if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1407
- continue; // let the previous action settle
1408
- }
1409
- // Sustained sub-realtime. Only downscale if the encoder — not a
1410
- // download-starved input — is the limit.
1411
- const bound = await this.#classifyTranscodeBound(session);
1412
- if (bound === "download") {
1413
- logger.info(
1414
- `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
1415
- `"${session.fileName}"; not downscaling (torrent is the bottleneck)`
1416
- );
1417
- session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
1418
- continue;
1419
- }
1420
- await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
1421
- }
1422
- }
1423
-
1424
- /**
1425
- * Decide whether a sustained sub-realtime transcode is limited by the encoder
1426
- * (CPU) or by a download-starved input. Compares the torrent's download rate
1427
- * with the source's average byte rate; a fully-downloaded file can never be
1428
- * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
1429
- * as CPU by the caller — the common case, logged as such).
1430
- *
1431
- * @param {HlsSession} session
1432
- * @returns {Promise<"cpu" | "download" | "unknown">}
1433
- */
1434
- async #classifyTranscodeBound(session) {
1435
- if (!this.getSourceStats) {
1436
- return "unknown";
1437
- }
1438
- let stats;
1439
- try {
1440
- stats = await this.getSourceStats(session.sourceKey, session.fileIndex);
1441
- } catch {
1442
- return "unknown";
1443
- }
1444
- if (!stats) {
1445
- return "unknown";
1446
- }
1447
- // A fully (or almost fully) downloaded file cannot be download-bound.
1448
- if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
1449
- return "cpu";
1450
- }
1451
- const duration = Number.isFinite(session.totalDurationSeconds) ? session.totalDurationSeconds : 0;
1452
- const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
1453
- const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
1454
- if (duration <= 0 || length <= 0) {
1455
- return "unknown"; // cannot compute the source byte rate
1456
- }
1457
- const sourceByteRate = length / duration;
1458
- return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
1459
- }
1460
-
1461
- /**
1462
- * Step a session one resolution rung down the budget ladder and restart the
1463
- * encode at the current segment with the lighter profile.
1464
- *
1465
- * @param {HlsSession} session
1466
- * @param {string} reasonText - Measurement summary for the log line.
1467
- * @param {"cpu" | "unknown" | "link"} bound
1468
- * @returns {Promise<void>}
1469
- */
1470
- async #applyBudgetDownshift(session, reasonText, bound) {
1471
- const nextIndex = session.budgetRungIndex + 1;
1472
- const rung = session.budgetLadder[nextIndex];
1473
- if (!rung) {
1474
- return;
1475
- }
1476
- const fps = Number.isInteger(session.outputFps) && session.outputFps > 0 ? session.outputFps : TRANSCODE_FPS;
1477
- session.budgetRungIndex = nextIndex;
1478
- session.budgetDownshifts += 1;
1479
- session.budgetLastActionAt = Date.now();
1480
- session.budgetSlowSince = 0;
1481
- session.encodeWidth = rung.width;
1482
- session.encodeHeight = rung.height;
1483
- session.softwarePreset = pickSoftwarePreset(this.softwarePresetBenchmark, rung.width * rung.height * fps);
1484
- // Restart at the current live-edge segment so the lighter profile takes over
1485
- // from where the viewer is watching (hard-restart tier).
1486
- const head = session.encodeStartIndex;
1487
- const processed = Number.isFinite(session.progress?.processedSeconds)
1488
- ? session.progress.processedSeconds
1489
- : this.#segmentStartTime(session, head);
1490
- const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
1491
- const boundLabel =
1492
- bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
1493
- logger.info(
1494
- `[budget] transcode ${session.id} ${boundLabel} ` +
1495
- `${reasonText} downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
1496
- `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
1497
- `restart at segment #${currentSeg} "${session.fileName}"`
1498
- );
1499
- await this.#startEncodeRun(session, currentSeg);
1500
- }
1501
-
1502
- /**
1503
- * (Re)start the ffmpeg encode run beginning at segment `startIndex`.
1504
- *
1505
- * Any ffmpeg process currently running for this session is terminated FIRST
1506
- * AND ITS EXIT IS AWAITED before the replacement is spawned into the same
1507
- * directory. This closes a real incident: a fire-and-forget SIGTERM does not
1508
- * mean the process is dead `ChildProcess.killed` reflects only that a
1509
- * signal was sent, not that the process exited (ffmpeg's own blocking read of
1510
- * our torrent-backed `/stream` input can defer signal handling for a long
1511
- * time while starved). On a rapid sequence of seeks this left multiple
1512
- * ffmpeg processes alive concurrently, all writing into the SAME session
1513
- * directory — observed as `failed to rename file segment-NNNNN.m4s.tmp`
1514
- * (a dying process racing a fresh one) and a zombie process still writing a
1515
- * `.tmp` file ~30s after being "killed" by two LATER restarts, even after the
1516
- * session had already been released. Multiple ffmpeg processes fighting over
1517
- * CPU and the same files on a weak host is what a seek could get "stuck" on.
1518
- *
1519
- * Because this now awaits, a NEWER restart request can arrive while an OLDER
1520
- * one is still waiting for the previous process to die. `encodeRunGeneration`
1521
- * resolves that: each call captures its own generation number, and after the
1522
- * await, a call whose generation was superseded aborts without spawning —
1523
- * only the LATEST requested target ever actually starts a process.
1524
- *
1525
- * Segment files are named with a global index (`-start_number`) so they
1526
- * always line up with the synthetic VOD playlist regardless of where
1527
- * encoding started — this is what makes server-side seeking work.
1528
- *
1529
- * @param {HlsSession} session
1530
- * @param {number} startIndex
1531
- * @returns {Promise<void>}
1532
- */
1533
- async #startEncodeRun(session, startIndex) {
1534
- const generation = ++session.encodeRunGeneration;
1535
- const previousFfmpeg = session.ffmpeg;
1536
- if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
1537
- try {
1538
- previousFfmpeg.kill("SIGTERM");
1539
- } catch {
1540
- // Best effort.
1541
- }
1542
- await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1543
- if (!hasChildExited(previousFfmpeg)) {
1544
- try {
1545
- previousFfmpeg.kill("SIGKILL");
1546
- } catch {
1547
- // Best effort.
1548
- }
1549
- await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1550
- }
1551
- }
1552
- // A newer restart (or disposal) won the race while we were waiting for the
1553
- // old process to die — it either already spawned its own replacement or
1554
- // there is nothing left to start. Do not also spawn from this stale call.
1555
- if (session.encodeRunGeneration !== generation || session.state === "disposed") {
1556
- return;
1557
- }
1558
-
1559
- const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
1560
- // 0-based output time of this segment, from the boundary table (uniform for
1561
- // re-encode, real keyframe for copy).
1562
- const startSeconds = this.#segmentStartTime(session, safeIndex);
1563
- const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
1564
-
1565
- // Terminate any existing encode process before starting a new one. The
1566
- // old process's exit handler no-ops because session.ffmpeg is reassigned
1567
- // below (it checks identity).
1568
- if (session.ffmpeg && !session.ffmpeg.killed) {
1569
- try {
1570
- session.ffmpeg.kill("SIGTERM");
1571
- } catch (_error) {
1572
- // Best effort.
1573
- }
1574
- }
1575
-
1576
- // Video: re-encode only when required, using the detected encoder
1577
- // (hardware-accelerated or software). The descriptor builds the filter +
1578
- // codec args (including keyframe alignment on segment boundaries).
1579
- const videoCodecArgs = session.transcodeVideo
1580
- ? this.videoEncoder.buildVideoArgs({
1581
- // Budget-selected encode box (may be below the client target on weak
1582
- // software hosts); falls back to the client target for hardware.
1583
- targetWidth: session.encodeWidth,
1584
- targetHeight: session.encodeHeight,
1585
- segmentDurationSec: this.segmentDurationSec,
1586
- // Source-inherited output rate (integer, capped); descriptors that
1587
- // use time-based keyframes just apply it as the frame rate.
1588
- fps: session.outputFps,
1589
- // Software-only; hardware descriptors ignore it.
1590
- preset: session.softwarePreset ?? undefined,
1591
- // HDR→SDR tone map (software path only; gated on filter availability).
1592
- tonemap: session.applyTonemap === true
1593
- })
1594
- : ["-c:v", "copy"];
1595
- const audioCodecArgs = session.transcodeAudio
1596
- ? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
1597
- : ["-c:a", "copy"];
1598
-
1599
- const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
1600
- // Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
1601
- // only applies when we actually re-encode the video track.
1602
- if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
1603
- args.push(...this.videoEncoder.inputArgs);
1604
- }
1605
- // Seek position in SOURCE time. For copy we seek to the real keyframe
1606
- // (startSeconds is already a real-keyframe offset from 0, so add back the
1607
- // container start time); for re-encode startSeconds is a plain grid offset.
1608
- const seekSeconds = session.transcodeVideo ? startSeconds : startSeconds + sourceStartTime;
1609
- // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
1610
- // keyframe (coarse, before -i safe because WE sourced it from ffprobe,
1611
- // not the container's own on-the-fly seek/index) and trim the short
1612
- // residual (bounded by the keyframe interval) precisely AFTER -i, which is
1613
- // always frame-accurate regardless of -accurate_seek.
1614
- //
1615
- // Root cause this works around: `-accurate_seek -ss X` before -i trusts the
1616
- // CONTAINER's own seek to land near X. For some containers (observed: AVI
1617
- // with VBR MP3 audio) that on-the-fly seek can point at a position with no
1618
- // valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
1619
- // "Header missing"), not just imprecisely, and repeatedly so since every
1620
- // retry re-tries the SAME bad container-computed position. A keyframe we
1621
- // read directly from the packet list is a position ffmpeg has already
1622
- // proven it can decode.
1623
- const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
1624
- ? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
1625
- : null;
1626
- if (snappedKeyframe !== null) {
1627
- const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
1628
- if (snappedKeyframe > 0) {
1629
- args.push("-ss", String(snappedKeyframe));
1630
- }
1631
- args.push("-i", session.inputUrl);
1632
- if (residualSeconds > 0) {
1633
- args.push("-ss", String(residualSeconds));
1634
- }
1635
- } else {
1636
- if (seekSeconds > 0) {
1637
- // No keyframe map (probe failed/timed out) — fall back to the previous
1638
- // behaviour: trust the container's own accurate seek.
1639
- args.push("-accurate_seek", "-ss", String(seekSeconds));
1640
- }
1641
- args.push("-i", session.inputUrl);
1642
- }
1643
- if (session.transcodeVideo) {
1644
- // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
1645
- // segment grid; relabel output onto the original timeline so segment N
1646
- // carries PTS = N × segmentDuration.
1647
- if (startSeconds > 0) {
1648
- args.push("-output_ts_offset", String(startSeconds));
1649
- }
1650
- } else {
1651
- // Branch B (video copied — only audio is transcoded): we cannot insert
1652
- // keyframes, so segments are cut at the source's own keyframes (the
1653
- // playlist boundaries were built from those keyframes). Keep the source's
1654
- // real timestamps (`-copyts`) so copied frames stay continuous across
1655
- // boundaries/seeks, and shift by -startTime so the output timeline is
1656
- // 0-based (a non-zero container start otherwise puts a hole at the very
1657
- // beginning and desyncs audio/video). Audio is transcoded on this timeline.
1658
- args.push("-copyts");
1659
- if (sourceStartTime !== 0) {
1660
- args.push("-output_ts_offset", String(-sourceStartTime));
1661
- }
1662
- }
1663
- args.push(
1664
- "-map",
1665
- "0:v:0?",
1666
- "-map",
1667
- // Type-relative audio track chosen by the viewer (default 0).
1668
- `0:a:${session.audioTrackIndex ?? 0}?`,
1669
- ...videoCodecArgs,
1670
- ...audioCodecArgs,
1671
- "-f",
1672
- "hls",
1673
- "-hls_time",
1674
- String(this.segmentDurationSec),
1675
- "-hls_list_size",
1676
- "0",
1677
- "-hls_flags",
1678
- "independent_segments+temp_file",
1679
- // fMP4 (CMAF) segments: codec config goes once into the init segment,
1680
- // referenced by `#EXT-X-MAP`. Each seek-restart run rewrites init.mp4, but
1681
- // it is codec-config only (position-independent), so getFileStream caches
1682
- // and serves the first one for the whole session.
1683
- "-hls_segment_type",
1684
- "fmp4",
1685
- "-hls_fmp4_init_filename",
1686
- SEGMENT_INIT_FILE_NAME,
1687
- "-start_number",
1688
- String(safeIndex),
1689
- "-hls_segment_filename",
1690
- "segment-%05d.m4s",
1691
- // ffmpeg writes its own playlist here; we ignore it and serve the
1692
- // synthetic VOD playlist instead (see getFileStream).
1693
- PLAYLIST_FILE_NAME
1694
- );
1695
-
1696
- const ffmpeg = spawn(this.ffmpegBin, args, {
1697
- cwd: session.dirPath,
1698
- stdio: ["ignore", "pipe", "pipe"]
1699
- });
1700
- session.ffmpeg = ffmpeg;
1701
- session.encodeStartIndex = safeIndex;
1702
- session.pendingRestartIndex = -1;
1703
- session.lastRestartAt = Date.now();
1704
- session.state = session.state === "disposed" ? "disposed" : "starting";
1705
- session.progress.state = "running";
1706
- session.progress.processedSeconds = startSeconds;
1707
- session.progress.startPositionSeconds = startSeconds;
1708
- session.progress.updatedAt = Date.now();
1709
- // Any (re)start resets the cumulative `speed` ffmpeg reports, so reset the
1710
- // realtime-budget slow window too — otherwise warm-up right after a user
1711
- // seek could be mis-counted as sustained sub-realtime and trigger a
1712
- // premature downscale.
1713
- session.budgetSlowSince = 0;
1714
-
1715
- logger.info(
1716
- `transcode ${session.id} encode-run from segment #${safeIndex} ` +
1717
- `(${formatSeconds(startSeconds)}) "${session.fileName}"`
1718
- );
1719
-
1720
- this.#wireEncodeProcess(session, ffmpeg);
1721
- }
1722
-
1723
- /**
1724
- * Rebase ffmpeg's `-progress` `out_time`/`out_time_ms` onto the SOURCE
1725
- * (absolute) timeline, so `session.progress.processedSeconds` is always
1726
- * comparable to `session.progress.startPositionSeconds` — which
1727
- * `computeProgressMetrics` and the client's own cushion-percent/ETA math
1728
- * both assume.
1729
- *
1730
- * Branch B (video copy, `-copyts`) already reports `out_time` on the
1731
- * source's absolute timeline — no rebase needed. Branch A (video re-encode)
1732
- * does NOT: `-output_ts_offset` (used there to relabel the MUXED output's
1733
- * timestamps onto the absolute grid) does not affect what `-progress`
1734
- * reports verified empirically (a 5s clip encoded with
1735
- * `-output_ts_offset 100` still reports `out_time` counting 0→5, not
1736
- * 100→105). Left unrebased, `processedSeconds` jumps from the post-restart
1737
- * placeholder (`session.progress.startPositionSeconds`, absolute) down to a
1738
- * near-zero RELATIVE value the moment real ffmpeg progress starts flowing —
1739
- * `processedSeconds - startPositionSeconds` then goes deeply negative,
1740
- * clamps to 0, and the client's cushion percent/ETA reads as permanently
1741
- * stuck at 0% for the whole run even while the encode is actively
1742
- * producing (field-diagnosed 2026-08-01: a re-encode session logged
1743
- * `processed=39.5 startPos=1824` at a healthy 6x realtime speed).
1744
- *
1745
- * @param {HlsSession} session
1746
- * @param {number} rawSeconds - As parsed from `out_time`/`out_time_ms`.
1747
- * @returns {number}
1748
- */
1749
- #toAbsoluteProcessedSeconds(session, rawSeconds) {
1750
- if (!session.transcodeVideo) {
1751
- return rawSeconds;
1752
- }
1753
- const offset = Number.isFinite(session.progress?.startPositionSeconds)
1754
- ? session.progress.startPositionSeconds
1755
- : 0;
1756
- return rawSeconds + offset;
1757
- }
1758
-
1759
- /**
1760
- * Wire stdout (progress), stderr (errors) and exit handlers for an ffmpeg
1761
- * encode process. Handlers no-op when the process has been superseded by a
1762
- * later encode run (identity check against `session.ffmpeg`).
1763
- *
1764
- * @param {HlsSession} session
1765
- * @param {import("node:child_process").ChildProcess} ffmpeg
1766
- * @returns {void}
1767
- */
1768
- #wireEncodeProcess(session, ffmpeg) {
1769
- ffmpeg.stdout.on("data", (chunk) => {
1770
- const lines = String(chunk).split(/\r?\n/);
1771
- for (const line of lines) {
1772
- const normalized = line.trim();
1773
- if (!normalized) {
1774
- continue;
1775
- }
1776
- const separator = normalized.indexOf("=");
1777
- if (separator <= 0) {
1778
- continue;
1779
- }
1780
- const key = normalized.slice(0, separator);
1781
- const value = normalized.slice(separator + 1);
1782
-
1783
- if (key === "out_time_ms") {
1784
- const numeric = Number(value);
1785
- if (Number.isFinite(numeric) && numeric >= 0) {
1786
- session.progress.processedSeconds = this.#toAbsoluteProcessedSeconds(session, numeric / MICROSECONDS_PER_SECOND);
1787
- }
1788
- } else if (key === "out_time") {
1789
- const parsed = parseFfmpegTimestamp(value);
1790
- if (parsed != null) {
1791
- session.progress.processedSeconds = this.#toAbsoluteProcessedSeconds(session, parsed);
1792
- }
1793
- } else if (key === "speed") {
1794
- session.progress.speed = value;
1795
- } else if (key === "progress") {
1796
- session.progress.state = value === "end" ? "ready" : "running";
1797
- }
1798
- const metrics = computeProgressMetrics(
1799
- session.progress.processedSeconds,
1800
- session.progress.totalSeconds,
1801
- session.progress.startPositionSeconds
1802
- );
1803
- session.progress.percent = metrics.percent;
1804
- session.progress.remainingSeconds = metrics.remainingSeconds;
1805
- session.progress.updatedAt = Date.now();
1806
- const shouldLog =
1807
- session.progress.percent != null &&
1808
- session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
1809
- if (shouldLog) {
1810
- session.progress.lastLoggedAt = session.progress.updatedAt;
1811
- logger.info(
1812
- `transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
1813
- `(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
1814
- ` speed=${session.progress.speed || "n/a"}`
1815
- );
1816
- }
1817
- }
1818
- });
1819
-
1820
- ffmpeg.stderr.on("data", (chunk) => {
1821
- const line = String(chunk).trim();
1822
- if (line.length > 0) {
1823
- session.lastError = line;
1824
- logger.warn(`ffmpeg ${session.id}: ${line}`);
1825
- }
1826
- });
1827
-
1828
- ffmpeg.on("error", (error) => {
1829
- if (session.ffmpeg !== ffmpeg) {
1830
- return;
1831
- }
1832
- session.state = "failed";
1833
- session.lastError = error instanceof Error ? error.message : String(error);
1834
- session.progress.state = "failed";
1835
- session.progress.updatedAt = Date.now();
1836
- logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
1837
- });
1838
-
1839
- ffmpeg.on("exit", (code, signal) => {
1840
- // Ignore the exit of a process that was superseded by a seek-restart.
1841
- if (session.ffmpeg !== ffmpeg) {
1842
- return;
1843
- }
1844
- if (session.state === "disposed") {
1845
- return;
1846
- }
1847
- if (code === 0) {
1848
- session.state = "ready";
1849
- session.progress.state = "ready";
1850
- session.progress.updatedAt = Date.now();
1851
- logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
1852
- return;
1853
- }
1854
- if (!session.lastError) {
1855
- session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
1856
- }
1857
- // Runtime safety net: if a hardware encode fails, downgrade this proxy to
1858
- // software encoding for all sessions and restart this one, so playback is
1859
- // never permanently broken by a hardware/driver issue.
1860
- if (session.transcodeVideo && this.videoEncoder.kind !== "software") {
1861
- const failedEncoder = this.videoEncoder.name;
1862
- this.videoEncoder = softwareDescriptor();
1863
- logger.warn(
1864
- `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
1865
- `(${session.lastError}); falling back to software libx264 and restarting`
1866
- );
1867
- void this.#startEncodeRun(session, session.encodeStartIndex);
1868
- return;
1869
- }
1870
- // Circuit-breaker bookkeeping: a seek-restart run that exits THIS fast
1871
- // never did real work — it failed at the seek/open step itself, not
1872
- // mid-stream (see SEEK_FAST_FAIL_MS). Track consecutive fast failures at
1873
- // the SAME target so #ensureEncodingFor/#fireSettledSeek (which check
1874
- // this below) can stop retrying instead of looping forever on a position
1875
- // that keeps failing even with the keyframe-snapped seek.
1876
- const elapsedMs = Date.now() - session.lastRestartAt;
1877
- if (elapsedMs < SEEK_FAST_FAIL_MS && session.encodeStartIndex > 0) {
1878
- if (session.seekFailureTarget === session.encodeStartIndex) {
1879
- session.seekFailureCount += 1;
1880
- } else {
1881
- session.seekFailureTarget = session.encodeStartIndex;
1882
- session.seekFailureCount = 1;
1883
- }
1884
- logger.warn(
1885
- `transcode ${session.id} fast failure at segment #${session.encodeStartIndex} ` +
1886
- `(${elapsedMs}ms) ${session.seekFailureCount}/${MAX_SEEK_FAILURES} consecutive`
1887
- );
1888
- } else {
1889
- // Real progress was made (or this was the very first run) — not a
1890
- // repeating seek failure. Reset the breaker.
1891
- session.seekFailureTarget = -1;
1892
- session.seekFailureCount = 0;
1893
- }
1894
- session.state = "failed";
1895
- session.progress.state = "failed";
1896
- session.progress.updatedAt = Date.now();
1897
- logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
1898
- });
1899
- }
1900
-
1901
- /**
1902
- * Ensure the encoder is producing (or will soon produce) the requested
1903
- * segment. If the segment is far ahead of the current encode head, or
1904
- * behind it, restart ffmpeg at that segment (server-side seek). Requests
1905
- * within the look-ahead window are served by waiting for the running encode.
1906
- *
1907
- * @param {HlsSession} session
1908
- * @param {number} index
1909
- * @returns {void}
1910
- */
1911
- #ensureEncodingFor(session, index) {
1912
- if (!session || session.state === "disposed" || index < 0) {
1913
- return;
1914
- }
1915
- const head = session.encodeStartIndex;
1916
- // Anchor the look-ahead window on the CURRENT encode position (start index +
1917
- // seconds already processed), not the run's start index. Otherwise a long
1918
- // run that has encoded well past `head` would needlessly restart for a
1919
- // request just ahead of the live edge.
1920
- const processed = Number.isFinite(session.progress?.processedSeconds)
1921
- ? session.progress.processedSeconds
1922
- : this.#segmentStartTime(session, head);
1923
- const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
1924
- const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
1925
- if (withinWindow) {
1926
- return;
1927
- }
1928
- // Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
1929
- // times in a row (fast failures see #wireEncodeProcess's exit handler).
1930
- // Stop auto-retrying it; session.state stays "failed" so getFileStream
1931
- // reports a clean, retryable error instead of looping forever. A DIFFERENT
1932
- // target (the viewer seeking elsewhere) is unaffected — it gets its own
1933
- // fresh attempt budget.
1934
- if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1935
- return;
1936
- }
1937
- // Far request = a server-side seek. Do NOT restart on the first one:
1938
- // debounce a burst of scattered requests into a single restart at the
1939
- // position the player ended on. Record the latest target and (re)arm the
1940
- // settle timer; the caller long-polls / the client retries meanwhile.
1941
- session.seekTarget = index;
1942
- if (session.seekSettleTimer) {
1943
- clearTimeout(session.seekSettleTimer);
1944
- } else {
1945
- session.seekFirstFarAt = Date.now();
1946
- }
1947
- const waited = Date.now() - session.seekFirstFarAt;
1948
- const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
1949
- session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
1950
- session.seekSettleTimer.unref?.();
1951
- }
1952
-
1953
- /**
1954
- * Fire a settled server-side seek: restart the encoder once at the target
1955
- * recorded during the settle window. Enforces the restart cooldown as a
1956
- * floor between actual restarts (re-arming for the remainder if still
1957
- * cooling down). No-op for a disposed session or a cleared target.
1958
- *
1959
- * @param {HlsSession} session
1960
- * @returns {void}
1961
- */
1962
- #fireSettledSeek(session) {
1963
- const target = session.seekTarget;
1964
- session.seekSettleTimer = null;
1965
- if (!session || session.state === "disposed" || target == null) {
1966
- session.seekTarget = null;
1967
- session.seekFirstFarAt = 0;
1968
- return;
1969
- }
1970
- // Circuit breaker (defense in depth): a timer armed before the cap was hit
1971
- // could still be pending when it was reached — do not fire the restart it
1972
- // was going to make. See the matching check in #ensureEncodingFor.
1973
- if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1974
- session.seekTarget = null;
1975
- session.seekFirstFarAt = 0;
1976
- return;
1977
- }
1978
- // Minimum gap between actual restarts (the settle already collapses bursts;
1979
- // this only guards back-to-back seeks). If still cooling down, re-arm once
1980
- // for the remaining cooldown instead of restarting now.
1981
- const sinceLastRestart = Date.now() - (session.lastRestartAt ?? 0);
1982
- if (sinceLastRestart < RESTART_COOLDOWN_MS) {
1983
- session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), RESTART_COOLDOWN_MS - sinceLastRestart);
1984
- session.seekSettleTimer.unref?.();
1985
- return;
1986
- }
1987
- session.seekTarget = null;
1988
- session.seekFirstFarAt = 0;
1989
- logger.info(`transcode ${session.id} seek settle restart at segment #${target}`);
1990
- void this.#startEncodeRun(session, target);
1991
- }
1992
-
1993
- /**
1994
- * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
1995
- * header, or until the session fails, or until the startup timeout elapses.
1996
- * Throws with message `"HLS playlist is still warming up."` on timeout.
1997
- *
1998
- * @param {HlsSession} session
1999
- * @returns {Promise<void>}
2000
- */
2001
- async waitUntilReady(session) {
2002
- // With a synthetic VOD playlist there is nothing to wait for: the playlist
2003
- // is generated from the probed duration and is available immediately.
2004
- // Individual segments are long-polled by the segment route as ffmpeg
2005
- // produces them.
2006
- if (session.useSyntheticPlaylist) {
2007
- if (session.state === "failed") {
2008
- throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
2009
- }
2010
- session.state = "ready";
2011
- return;
2012
- }
2013
-
2014
- const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
2015
- const deadline = Date.now() + this.startupWaitMs;
2016
-
2017
- while (Date.now() < deadline) {
2018
- if (session.state === "failed") {
2019
- throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
2020
- }
2021
- try {
2022
- await access(playlistPath);
2023
- const text = await readFile(playlistPath, "utf8");
2024
- if (text.includes("#EXTM3U")) {
2025
- session.state = "ready";
2026
- return;
2027
- }
2028
- } catch (_error) {
2029
- // Playlist is not ready yet.
2030
- }
2031
- await delay(250);
2032
- }
2033
-
2034
- throw new Error("HLS playlist is still warming up.");
2035
- }
2036
-
2037
- /**
2038
- * Open a read stream for an HLS segment or playlist file from a session.
2039
- *
2040
- * @param {string} sessionId
2041
- * @param {string} fileName - Must match the playlist or segment name pattern.
2042
- * @returns {Promise<
2043
- * | { kind: "not-found" }
2044
- * | { kind: "warming-up" }
2045
- * | { kind: "failed"; message: string }
2046
- * | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
2047
- * >}
2048
- */
2049
- async getFileStream(sessionId, fileName) {
2050
- if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName)) {
2051
- return { kind: "not-found" };
2052
- }
2053
- const session = this.sessionsById.get(sessionId);
2054
- if (!session) {
2055
- return { kind: "not-found" };
2056
- }
2057
- if (session.state === "failed") {
2058
- return {
2059
- kind: "failed",
2060
- message: session.lastError || "ffmpeg failed for this transcode session."
2061
- };
2062
- }
2063
- session.lastAccessedAt = Date.now();
2064
-
2065
- // Serve the synthetic VOD playlist (full duration, terminated with
2066
- // #EXT-X-ENDLIST) so the player gets the correct total length and a fully
2067
- // seekable timeline up-front, independent of how far ffmpeg has encoded.
2068
- if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
2069
- return {
2070
- kind: "file",
2071
- stream: Readable.from([session.playlistText]),
2072
- contentType: "application/vnd.apple.mpegurl",
2073
- isPlaylist: true
2074
- };
2075
- }
2076
-
2077
- // The fMP4 init segment (referenced by #EXT-X-MAP). It is codec-config only
2078
- // and position-independent, but each seek-restart run REWRITES init.mp4, so
2079
- // cache the FIRST one and always serve that — otherwise the init the player
2080
- // fetched could differ from a later run's, breaking playback after a seek.
2081
- //
2082
- // ffmpeg creates init.mp4 before it has finished writing the fMP4 header
2083
- // boxes into it (unlike segments, its write is not gated behind an atomic
2084
- // rename), so a read can race a moment where the file EXISTS but is still
2085
- // EMPTY. Root cause of a real incident: that empty read used to be cached
2086
- // as `session.initBytes` — a zero-length Buffer is still a truthy object,
2087
- // so `if (session.initBytes)` treated it as "already resolved" and served
2088
- // the empty file for the rest of the session's life, permanently breaking
2089
- // playback (hls.js can never initialize its SourceBuffer from an empty
2090
- // init segment) while the transcode itself kept encoding normally. Guard
2091
- // on non-empty content on both the cache check and the fresh read, so an
2092
- // empty read is treated as not-yet-ready and the caller's long-poll keeps
2093
- // retrying until ffmpeg has actually written the header.
2094
- if (fileName === SEGMENT_INIT_FILE_NAME) {
2095
- if (session.initBytes && session.initBytes.length > 0) {
2096
- return { kind: "file", stream: Readable.from([session.initBytes]), contentType: "video/mp4", isPlaylist: false };
2097
- }
2098
- try {
2099
- const bytes = await readFile(path.join(session.dirPath, SEGMENT_INIT_FILE_NAME));
2100
- if (bytes.length === 0) {
2101
- return { kind: "warming-up" };
2102
- }
2103
- session.initBytes = bytes;
2104
- return { kind: "file", stream: Readable.from([bytes]), contentType: "video/mp4", isPlaylist: false };
2105
- } catch {
2106
- // Not produced yet — the encode run started at session creation writes
2107
- // it early; the caller long-polls until it appears.
2108
- return { kind: "warming-up" };
2109
- }
2110
- }
2111
-
2112
- const filePath = path.join(session.dirPath, fileName);
2113
- try {
2114
- await access(filePath);
2115
- const isPlaylist = fileName === PLAYLIST_FILE_NAME;
2116
- // Cold-start: log the first servable SEGMENT of this session exactly once
2117
- // — the time from session-create entry to a playable first segment.
2118
- if (!isPlaylist && !session.firstSegmentLogged) {
2119
- session.firstSegmentLogged = true;
2120
- logger.info(
2121
- `cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
2122
- );
2123
- }
2124
- return {
2125
- kind: "file",
2126
- stream: isPlaylist
2127
- ? createReadStream(filePath)
2128
- : createReadStream(filePath, { highWaterMark: SEGMENT_READ_HIGH_WATER_MARK }),
2129
- contentType:
2130
- fileName === PLAYLIST_FILE_NAME
2131
- ? "application/vnd.apple.mpegurl"
2132
- : "video/mp4",
2133
- isPlaylist: fileName === PLAYLIST_FILE_NAME
2134
- };
2135
- } catch (_error) {
2136
- // File not produced yet.
2137
- }
2138
-
2139
- // A segment was requested that ffmpeg has not produced yet. Decide whether
2140
- // to wait for the current encode run to reach it or to restart the encoder
2141
- // at this position (server-side seeking). The caller long-polls.
2142
- if (fileName !== PLAYLIST_FILE_NAME) {
2143
- this.#ensureEncodingFor(session, segmentIndexFromName(fileName));
2144
- }
2145
- return { kind: "warming-up" };
2146
- }
2147
-
2148
- /**
2149
- * Dispose all sessions that have been idle longer than `sessionTtlMs`.
2150
- * Called automatically on the cleanup interval.
2151
- *
2152
- * @returns {Promise<void>}
2153
- */
2154
- async cleanupExpired() {
2155
- const now = Date.now();
2156
- const idsToDispose = [];
2157
- for (const [sessionId, session] of this.sessionsById.entries()) {
2158
- if (now - session.lastAccessedAt > this.sessionTtlMs) {
2159
- idsToDispose.push(sessionId);
2160
- }
2161
- }
2162
- for (const sessionId of idsToDispose) {
2163
- await this.disposeSession(sessionId);
2164
- }
2165
- }
2166
-
2167
- /**
2168
- * Return a progress snapshot for the given session, or `null` if not found.
2169
- * Also refreshes `lastAccessedAt` to prevent the session from expiring.
2170
- *
2171
- * @param {string} sessionId
2172
- * @returns {Promise<object | null>}
2173
- */
2174
- async getSessionProgress(sessionId) {
2175
- if (!isSafeSessionId(sessionId)) {
2176
- return null;
2177
- }
2178
- const session = this.sessionsById.get(sessionId);
2179
- if (!session) {
2180
- return null;
2181
- }
2182
- session.lastAccessedAt = Date.now();
2183
- const warmupTotalSeconds = this.startupWaitMs / 1000;
2184
- const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
2185
- const isWarmupPhase = session.state === "starting" || session.progress.state === "starting";
2186
- const warmupPercent = isWarmupPhase
2187
- ? Math.max(0, Math.min(100, (warmupElapsedSeconds / warmupTotalSeconds) * 100))
2188
- : null;
2189
- const warmupRemainingSeconds = isWarmupPhase
2190
- ? Math.max(0, warmupTotalSeconds - warmupElapsedSeconds)
2191
- : null;
2192
- // Observed OUTPUT bitrate (Mbit/s) from recently completed segment sizes —
2193
- // already computed for the viewer-link budget check (#checkLinkBudget); also
2194
- // exposed here so the browser can turn its OWN measured link throughput into
2195
- // a "content-seconds delivered per wall-clock second" rate for the unified
2196
- // three-stage ETA (download / transcode / delivery), the same way the
2197
- // transcode's own `speed` already is one. Null when not enough segments yet.
2198
- const outputMbps = await this.#observedStreamMbps(session);
2199
- return {
2200
- sessionId: session.id,
2201
- state: session.progress.state,
2202
- processedSeconds: session.progress.processedSeconds,
2203
- startPositionSeconds: session.progress.startPositionSeconds ?? 0,
2204
- totalSeconds: session.progress.totalSeconds,
2205
- percent: session.progress.percent,
2206
- remainingSeconds: session.progress.remainingSeconds,
2207
- warmupPercent,
2208
- warmupRemainingSeconds,
2209
- // Segment length, so the browser can show progress toward the FIRST
2210
- // segment (the only thing it waits for before playback starts) instead
2211
- // of a percentage of the whole-file transcode.
2212
- segmentDurationSec: this.segmentDurationSec,
2213
- speed: session.progress.speed,
2214
- outputMbps,
2215
- updatedAt: session.progress.updatedAt,
2216
- error: session.state === "failed" ? session.lastError : ""
2217
- };
2218
- }
2219
-
2220
- /**
2221
- * Remove a consumer from a session. Disposes the session when the last
2222
- * consumer leaves.
2223
- *
2224
- * @param {string} sessionId
2225
- * @param {string} [consumerId=""]
2226
- * @param {string} [reason=""] - Human-readable reason shown in logs.
2227
- * @returns {Promise<boolean>} `false` if the session was not found.
2228
- */
2229
- async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
2230
- if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
2231
- return false;
2232
- }
2233
- const session = this.sessionsById.get(sessionId);
2234
- if (!session) {
2235
- return false;
2236
- }
2237
- if (!(session.consumers instanceof Set)) {
2238
- session.consumers = new Set();
2239
- }
2240
- session.consumers.delete(consumerId);
2241
- session.lastAccessedAt = Date.now();
2242
- const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
2243
- logger.info(
2244
- `consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
2245
- `remaining=${session.consumers.size}`
2246
- );
2247
- if (session.consumers.size > 0) {
2248
- return true;
2249
- }
2250
- await this.disposeSession(sessionId);
2251
- return true;
2252
- }
2253
-
2254
- /**
2255
- * Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
2256
- *
2257
- * @param {string} sessionId
2258
- * @returns {Promise<void>}
2259
- */
2260
- async disposeSession(sessionId) {
2261
- const session = this.sessionsById.get(sessionId);
2262
- if (!session) {
2263
- return;
2264
- }
2265
- session.state = "disposed";
2266
- this.sessionsById.delete(sessionId);
2267
- this.sessionIdBySource.delete(session.sourceMapKey);
2268
-
2269
- // Clear any pending seek-settle timer so it cannot fire and restart a
2270
- // disposed session.
2271
- if (session.seekSettleTimer) {
2272
- clearTimeout(session.seekSettleTimer);
2273
- session.seekSettleTimer = null;
2274
- }
2275
-
2276
- if (session.ffmpeg && !session.ffmpeg.killed) {
2277
- session.ffmpeg.kill("SIGTERM");
2278
- await waitForChildExit(session.ffmpeg);
2279
- }
2280
- try {
2281
- await rm(session.dirPath, { recursive: true, force: true });
2282
- } catch (error) {
2283
- const message = error instanceof Error ? error.message : String(error);
2284
- logger.warn(`failed to cleanup HLS temp dir: ${message}`);
2285
- }
2286
- }
2287
-
2288
- /**
2289
- * Stop the cleanup timer, dispose all active sessions, and attempt to
2290
- * remove the shared temp root directory if it is empty.
2291
- * Called by Fastify's `onClose` hook during graceful shutdown.
2292
- *
2293
- * @returns {Promise<void>}
2294
- */
2295
- async disposeAll() {
2296
- clearInterval(this.cleanupTimer);
2297
- clearInterval(this.budgetTimer);
2298
- const activeIds = Array.from(this.sessionsById.keys());
2299
- for (const sessionId of activeIds) {
2300
- await this.disposeSession(sessionId);
2301
- }
2302
- const rootDir = path.join(os.tmpdir(), "torrent-tv-hls");
2303
- try {
2304
- const dirs = await readdir(rootDir);
2305
- if (dirs.length === 0) {
2306
- await rm(rootDir, { recursive: true, force: true });
2307
- }
2308
- } catch (_error) {
2309
- // Best effort cleanup.
2310
- }
2311
- }
2312
- }
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
+
10
+ import { createReadStream } from "node:fs";
11
+ import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
12
+ import { Readable } from "node:stream";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import { spawn } from "node:child_process";
17
+ import { logger } from "../utils/logger.js";
18
+ import {
19
+ softwareDescriptor,
20
+ chooseSoftwareEncodeSettings,
21
+ pickSoftwarePreset,
22
+ TRANSCODE_FPS,
23
+ chooseOutputFps
24
+ } from "./hwaccel.js";
25
+ import {
26
+ parseFfmpegDurationSeconds,
27
+ parseFfmpegStartTimeSeconds,
28
+ parseFfmpegVideoDimensions,
29
+ parseFfmpegVideoFps,
30
+ parseFfmpegHdr
31
+ } from "./ffmpeg-banner.js";
32
+ import { resolveSegmentFormat } from "./segment-formats/index.js";
33
+
34
+ const PLAYLIST_FILE_NAME = "index.m3u8";
35
+ const CLEANUP_INTERVAL_MS = 30_000;
36
+ const DEFAULT_SEGMENT_DURATION_SEC = 4;
37
+ // How many segments ahead of the current encode head a missing-segment request
38
+ // is allowed to be before we restart ffmpeg at that position (server-side seek).
39
+ // Requests within the window are served by waiting for the running encode.
40
+ const MAX_LOOKAHEAD_SEGMENTS = 8;
41
+ // After a seek-restart, ignore competing restart requests for this long. The
42
+ // synthetic VOD playlist lets the player request distant segments in quick
43
+ // succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
44
+ // between positions, restarting endlessly and producing nothing.
45
+ const RESTART_COOLDOWN_MS = 4_000;
46
+ // Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
47
+ // continuously while it encodes; when it hangs mid-file (alive, but producing
48
+ // no output and no stderr — a deadlock, e.g. a stalled input read), that output
49
+ // stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
50
+ // window is being demanded but progress has not advanced for this long, the
51
+ // encoder is wedged (observed: the segment 503s forever). Treat it like a seek
52
+ // and restart ffmpeg at the demanded segment. Conservative a slow-but-moving
53
+ // encode keeps advancing `updatedAt`, so this only fires on a true freeze.
54
+ const ENCODER_STALL_MS = 12_000;
55
+ // Seek debounce. A far (out-of-window) segment request is a server-side seek.
56
+ // Rather than restart ffmpeg on the first one, wait a short quiet period:
57
+ // further far requests re-arm it and update the target to the latest index, so
58
+ // a scrub that emits a burst of scattered requests (e.g. iOS native HLS firing
59
+ // 367,732,369,368,370 seconds apart) collapses to ONE restart at the position
60
+ // the player ended on, instead of ping-ponging ffmpeg between positions and
61
+ // producing nothing.
62
+ const SEEK_SETTLE_MS = 1_200;
63
+ // Hard cap on the total settle wait, measured from the first far request of a
64
+ // burst, so a still-moving scrubber cannot delay a genuine seek forever.
65
+ const SEEK_SETTLE_MAX_MS = 2_500;
66
+ // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
67
+ // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
68
+ // the same session directory. See #startEncodeRun.
69
+ const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
70
+ // A seek-restart run that exits this fast never did real work — it failed at
71
+ // the seek/open step itself (container demux error, bad audio frame boundary,
72
+ // etc.), not mid-stream. Used to tell a genuine seek failure apart from a
73
+ // later, unrelated crash so the circuit breaker below only counts the former.
74
+ const SEEK_FAST_FAIL_MS = 2_000;
75
+ // Circuit breaker: consecutive fast failures AT THE SAME target before we stop
76
+ // auto-retrying and leave the session in its terminal "failed" state (surfaced
77
+ // to the client as a clean, retryable error) instead of looping forever. The
78
+ // keyframe-snap seek (see #startEncodeRun) already fixes the dominant failure
79
+ // mode (an unreliable container-computed seek position); this is a safety net
80
+ // for whatever residual case still fails not a second competing "fix" that
81
+ // blindly retries the identical command hoping for a different result.
82
+ const MAX_SEEK_FAILURES = 3;
83
+ // Idle TTL: a session is disposed this long after the last segment/playlist
84
+ // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
85
+ // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
86
+ // also backs the seamless auto-reconnect). ffmpeg stops producing at the
87
+ // look-ahead cap when idle, so a lingering session costs retained segments on
88
+ // disk, not sustained CPU. Active playback refreshes the timer on every segment
89
+ // fetch, so it never expires mid-watch.
90
+ const DEFAULT_SESSION_TTL_MS = 10 * 60 * 1000;
91
+ const DEFAULT_STARTUP_WAIT_MS = 5_000;
92
+ // Realtime budget runtime downswitch (software encoder only). Periodically
93
+ // check each active software-transcode session's ffmpeg `speed`; when it stays
94
+ // below realtime for a sustained window AND the input is not download-starved
95
+ // (so the limit is the encoder, not the torrent), step down one resolution rung
96
+ // and restart at the current segment. Conservative so it never thrashes: a long
97
+ // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
98
+ const BUDGET_CHECK_INTERVAL_MS = 5_000;
99
+ // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
100
+ // realtime resets the slow window (hysteresis).
101
+ const BUDGET_SPEED_SLOW = 0.95;
102
+ const BUDGET_SPEED_OK = 1.0;
103
+ // Slow must persist this long before a downshift (absorbs warm-up + brief
104
+ // complex scenes; the cumulative average won't dip this long unless the host
105
+ // genuinely can't keep up).
106
+ const BUDGET_SUSTAINED_MS = 15_000;
107
+ // After a downshift, wait this long before another (lets the new profile settle
108
+ // and a fresh cumulative average build).
109
+ const BUDGET_ACTION_COOLDOWN_MS = 30_000;
110
+ // Never step down more than this many rungs below the startup choice.
111
+ const BUDGET_MAX_DOWNSHIFTS = 3;
112
+ // The input counts as "keeping up" when the torrent downloads at least this
113
+ // multiple of the source's average byte rate. Below it (and not yet fully
114
+ // downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
115
+ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
116
+ // Viewer-link adaptation (adaptive bitrate, part b). The browser reports its
117
+ // measured data-channel throughput + buffered seconds every ~10 s; when a
118
+ // FRESH report shows the usable link (reported × safety margin) sustainedly
119
+ // below the observed produced bitrate AND the viewer's buffer is low, the
120
+ // budget loop steps the encode one rung down — same machinery, cooldown and
121
+ // floor as the CPU trigger. Manual-quality sessions are inherently exempt
122
+ // (their budgetLadder is null).
123
+ const LINK_REPORT_FRESH_MS = 30_000;
124
+ // Usable share of the reported link (protocol overhead + measurement noise).
125
+ const LINK_SAFETY = 0.8;
126
+ // Deficit must persist this long before acting (absorbs one slow segment).
127
+ const LINK_SLOW_WINDOW_MS = 15_000;
128
+ // Only act while the viewer is actually running dry; a comfortable buffer
129
+ // (e.g. paused playback filling ahead) suppresses the trigger.
130
+ const LINK_LOW_BUFFER_SEC = 10;
131
+ // Observed produced bitrate: average over this many recently completed
132
+ // segments (the newest file on disk may still be written and is excluded).
133
+ const LINK_OBSERVED_SEGMENTS = 5;
134
+ const MICROSECONDS_PER_SECOND = 1_000_000;
135
+ const PROGRESS_LOG_INTERVAL_MS = 5_000;
136
+ // Read segment files in large blocks so the body is delivered to the data
137
+ // channel in few, big chunks. On a busy ARM host the in-process WebTorrent
138
+ // hashing starves the event loop in bursts, so fewer read iterations means
139
+ // far less time lost between chunks while serving the first segments.
140
+ const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
141
+
142
+ /**
143
+ * Resolve after a given number of milliseconds.
144
+ *
145
+ * @param {number} ms
146
+ * @returns {Promise<void>}
147
+ */
148
+ function delay(ms) {
149
+ return new Promise((resolve) => {
150
+ setTimeout(resolve, ms);
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Wait for a child process to exit, with a hard timeout fallback.
156
+ *
157
+ * @param {import("node:child_process").ChildProcess} child
158
+ * @param {number} [timeoutMs=2000]
159
+ * @returns {Promise<void>}
160
+ */
161
+ function waitForChildExit(child, timeoutMs = 2_000) {
162
+ return new Promise((resolve) => {
163
+ let settled = false;
164
+ const finish = () => {
165
+ if (settled) {
166
+ return;
167
+ }
168
+ settled = true;
169
+ resolve();
170
+ };
171
+ child.once("exit", finish);
172
+ setTimeout(finish, timeoutMs);
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Whether a child process has genuinely exited. `ChildProcess.killed` only
178
+ * means `.kill()` was called — the process can stay alive well after that
179
+ * (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
180
+ * only set once the `exit` event has actually fired, so this is the reliable
181
+ * check before treating a directory/file as free for a new process to use.
182
+ *
183
+ * @param {import("node:child_process").ChildProcess} child
184
+ * @returns {boolean}
185
+ */
186
+ function hasChildExited(child) {
187
+ return child.exitCode !== null || child.signalCode !== null;
188
+ }
189
+
190
+ /**
191
+ * Convert a bind-all host address to the loopback address so that
192
+ * the HLS input URL is always reachable from the same machine.
193
+ *
194
+ * @param {string} host
195
+ * @returns {string}
196
+ */
197
+ function toLoopbackHost(host) {
198
+ if (host === "0.0.0.0" || host === "::") {
199
+ return "127.0.0.1";
200
+ }
201
+ return host;
202
+ }
203
+
204
+ /**
205
+ * Build the HTTP base URL (scheme + host + port) for the local proxy server.
206
+ *
207
+ * @param {string} host - Bind host (may be "0.0.0.0" or "::").
208
+ * @param {number} port
209
+ * @returns {string} e.g. "http://127.0.0.1:9090"
210
+ */
211
+ function buildHttpBaseUrl(host, port) {
212
+ const url = new URL("http://localhost");
213
+ url.hostname = toLoopbackHost(host);
214
+ url.port = String(port);
215
+ return url.origin;
216
+ }
217
+
218
+ /**
219
+ * Return the temporary directory path for a given HLS session.
220
+ *
221
+ * @param {string} sessionId - UUID of the session.
222
+ * @returns {string}
223
+ */
224
+ function createSessionDirPath(sessionId) {
225
+ return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
226
+ }
227
+
228
+ /**
229
+ * Guard against path traversal by validating that a session ID is a UUID.
230
+ *
231
+ * @param {unknown} value
232
+ * @returns {boolean}
233
+ */
234
+ function isSafeSessionId(value) {
235
+ return /^[a-f0-9-]{36}$/i.test(value);
236
+ }
237
+
238
+ /**
239
+ * Guard against path traversal by restricting file names to the known
240
+ * playlist and segment patterns produced by ffmpeg. Which segment names are
241
+ * legal depends on the active container, so the format decides.
242
+ *
243
+ * @param {string} fileName
244
+ * @param {import("./segment-formats/index.js").SegmentFormat} segmentFormat
245
+ * @returns {boolean}
246
+ */
247
+ function isSafeFileName(fileName, segmentFormat) {
248
+ return (
249
+ fileName === PLAYLIST_FILE_NAME ||
250
+ (segmentFormat.initFileName !== null && fileName === segmentFormat.initFileName) ||
251
+ segmentFormat.isSegmentFileName(fileName)
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
257
+ * Returns `null` if the value is absent or malformed.
258
+ *
259
+ * @param {string | undefined} value
260
+ * @returns {number | null}
261
+ */
262
+ function parseFfmpegTimestamp(value) {
263
+ if (!value || typeof value !== "string") {
264
+ return null;
265
+ }
266
+ const parts = value.split(":");
267
+ if (parts.length !== 3) {
268
+ return null;
269
+ }
270
+ const hours = Number(parts[0]);
271
+ const minutes = Number(parts[1]);
272
+ const seconds = Number(parts[2]);
273
+ if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
274
+ return null;
275
+ }
276
+ return hours * 3600 + minutes * 60 + seconds;
277
+ }
278
+
279
+ /**
280
+ * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
281
+ *
282
+ * @param {number} seconds
283
+ * @returns {string}
284
+ */
285
+ function formatSeconds(seconds) {
286
+ if (!Number.isFinite(seconds) || seconds < 0) {
287
+ return "n/a";
288
+ }
289
+ const total = Math.floor(seconds);
290
+ const hours = Math.floor(total / 3600);
291
+ const minutes = Math.floor((total % 3600) / 60);
292
+ const rest = total % 60;
293
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
294
+ }
295
+
296
+ /**
297
+ * Compute derived progress metrics from raw ffmpeg output values.
298
+ *
299
+ * When `startPositionSeconds` is provided (seek-restart case), progress is
300
+ * computed relative to the remaining duration after the seek point so the
301
+ * percent value reflects transcoding of the requested segment, not the whole
302
+ * file.
303
+ *
304
+ * @param {number} processedSeconds - Output timestamp of last encoded frame.
305
+ * @param {number | null} totalSeconds - Total duration, or `null` if unknown.
306
+ * @param {number} [startPositionSeconds=0] - Seek offset used for this session.
307
+ * @returns {{ totalSeconds: number | null, percent: number | null, remainingSeconds: number | null, processedSeconds: number }}
308
+ */
309
+ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSeconds = 0) {
310
+ const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
311
+ const startOffset = Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
312
+ ? startPositionSeconds
313
+ : 0;
314
+ if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
315
+ return { totalSeconds: null, percent: null, remainingSeconds: null, processedSeconds: processed };
316
+ }
317
+ const safeTotal = totalSeconds;
318
+ const segmentDuration = Math.max(1, safeTotal - startOffset);
319
+ const segmentProcessed = Math.max(0, processed - startOffset);
320
+ const percent = Math.max(0, Math.min(100, (segmentProcessed / segmentDuration) * 100));
321
+ const remainingSeconds = Math.max(0, safeTotal - processed);
322
+ return {
323
+ totalSeconds: safeTotal,
324
+ percent,
325
+ remainingSeconds,
326
+ processedSeconds: processed
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Run a short ffmpeg probe to extract the total duration AND video resolution
332
+ * of a stream from the container header. Both are printed almost immediately
333
+ * (before any decoding), so this returns as soon as they are seen; an 8 s
334
+ * timeout guards the rest.
335
+ *
336
+ * @param {string} ffmpegBin - Path to the ffmpeg executable.
337
+ * @param {string | URL} inputUrl - URL of the stream to probe.
338
+ * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
339
+ */
340
+ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
341
+ return new Promise((resolve) => {
342
+ const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
343
+ stdio: ["ignore", "ignore", "pipe"],
344
+ windowsHide: true
345
+ });
346
+ let stderr = "";
347
+ let settled = false;
348
+ const finish = () => {
349
+ if (settled) {
350
+ return;
351
+ }
352
+ settled = true;
353
+ const dims = parseFfmpegVideoDimensions(stderr);
354
+ resolve({
355
+ durationSeconds: parseFfmpegDurationSeconds(stderr),
356
+ width: dims.width,
357
+ height: dims.height,
358
+ fps: parseFfmpegVideoFps(stderr),
359
+ startTime: parseFfmpegStartTimeSeconds(stderr),
360
+ isHdr: parseFfmpegHdr(stderr)
361
+ });
362
+ };
363
+ const timeoutId = setTimeout(() => {
364
+ if (!ffmpeg.killed) {
365
+ ffmpeg.kill("SIGTERM");
366
+ }
367
+ finish();
368
+ }, 8_000);
369
+ ffmpeg.stderr.on("data", (chunk) => {
370
+ stderr += String(chunk);
371
+ // The header ("Duration:" then the "Video: … WxH" stream line) is printed
372
+ // before any decoding. Bail as soon as both are present instead of letting
373
+ // `-f null -` decode the whole stream until the 8 s timeout.
374
+ const duration = parseFfmpegDurationSeconds(stderr);
375
+ const dims = parseFfmpegVideoDimensions(stderr);
376
+ if (duration != null && dims.width != null) {
377
+ clearTimeout(timeoutId);
378
+ if (!ffmpeg.killed) {
379
+ ffmpeg.kill("SIGTERM");
380
+ }
381
+ finish();
382
+ }
383
+ });
384
+ ffmpeg.on("error", () => {
385
+ clearTimeout(timeoutId);
386
+ finish();
387
+ });
388
+ ffmpeg.on("exit", () => {
389
+ clearTimeout(timeoutId);
390
+ finish();
391
+ });
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Compute the actual output resolution ffmpeg will produce: the target box
397
+ * capped to the source (never upscaled), preserving aspect, divisible by 2.
398
+ * Mirrors the `scale='min(w,iw)':'min(h,ih)':force_original_aspect_ratio=decrease`
399
+ * filter. Returns `null` when the source size is unknown.
400
+ *
401
+ * @param {number} targetWidth
402
+ * @param {number} targetHeight
403
+ * @param {number | null} sourceWidth
404
+ * @param {number | null} sourceHeight
405
+ * @returns {{ w: number, h: number } | null}
406
+ */
407
+ function computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight) {
408
+ const sw = Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : 0;
409
+ const sh = Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : 0;
410
+ if (!sw || !sh) {
411
+ return null;
412
+ }
413
+ const tw = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : sw;
414
+ const th = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : sh;
415
+ const scale = Math.min(tw / sw, th / sh, 1);
416
+ let w = Math.round(sw * scale);
417
+ let h = Math.round(sh * scale);
418
+ w -= w % 2;
419
+ h -= h % 2;
420
+ return { w: Math.max(2, w), h: Math.max(2, h) };
421
+ }
422
+
423
+ /**
424
+ * Resolve the ffprobe binary path from the ffmpeg path (same directory / name).
425
+ *
426
+ * @param {string} ffmpegBin
427
+ * @returns {string}
428
+ */
429
+ function ffprobeBinFor(ffmpegBin) {
430
+ if (typeof ffmpegBin !== "string" || ffmpegBin.length === 0) {
431
+ return "ffprobe";
432
+ }
433
+ if (/ffmpeg(\.exe)?$/i.test(ffmpegBin)) {
434
+ return ffmpegBin.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
435
+ }
436
+ return "ffprobe";
437
+ }
438
+
439
+ /**
440
+ * Probe the source video stream's keyframe timestamps (seconds, in the source
441
+ * timeline) via ffprobe packet flags. Used for the video-copy path, where we
442
+ * cannot insert keyframes: the synthetic playlist's segment boundaries must
443
+ * match the source's real keyframe positions or the player sees gaps on seek.
444
+ *
445
+ * Time-bounded; returns `null` on failure/timeout (caller falls back to a
446
+ * uniform grid). NOTE: reading all video packets streams much of the file from
447
+ * the torrent, so for large files this may time out and fall back.
448
+ *
449
+ * @param {string} ffmpegBin
450
+ * @param {string | URL} inputUrl
451
+ * @param {number} [timeoutMs]
452
+ * @returns {Promise<number[] | null>} Sorted keyframe times, or null.
453
+ */
454
+ async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000) {
455
+ return new Promise((resolve) => {
456
+ let proc;
457
+ try {
458
+ proc = spawn(
459
+ ffprobeBinFor(ffmpegBin),
460
+ [
461
+ "-v", "error",
462
+ "-select_streams", "v:0",
463
+ "-show_entries", "packet=pts_time,flags",
464
+ "-of", "csv=p=0",
465
+ String(inputUrl)
466
+ ],
467
+ { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
468
+ );
469
+ } catch {
470
+ resolve(null);
471
+ return;
472
+ }
473
+ let stdout = "";
474
+ let settled = false;
475
+ const finish = (value) => {
476
+ if (settled) {
477
+ return;
478
+ }
479
+ settled = true;
480
+ resolve(value);
481
+ };
482
+ const timer = setTimeout(() => {
483
+ try {
484
+ if (!proc.killed) {
485
+ proc.kill("SIGTERM");
486
+ }
487
+ } catch {
488
+ // ignore
489
+ }
490
+ finish(null);
491
+ }, timeoutMs);
492
+ proc.stdout.on("data", (chunk) => {
493
+ stdout += String(chunk);
494
+ });
495
+ proc.on("error", () => {
496
+ clearTimeout(timer);
497
+ finish(null);
498
+ });
499
+ proc.on("exit", (code) => {
500
+ clearTimeout(timer);
501
+ if (code !== 0) {
502
+ finish(null);
503
+ return;
504
+ }
505
+ const times = [];
506
+ for (const line of stdout.split("\n")) {
507
+ // Each line: "<pts_time>,<flags>" e.g. "12.345000,K__"
508
+ const comma = line.indexOf(",");
509
+ if (comma < 0) {
510
+ continue;
511
+ }
512
+ const flags = line.slice(comma + 1);
513
+ if (!flags.includes("K")) {
514
+ continue;
515
+ }
516
+ const t = Number(line.slice(0, comma));
517
+ if (Number.isFinite(t)) {
518
+ times.push(t);
519
+ }
520
+ }
521
+ times.sort((a, b) => a - b);
522
+ finish(times.length > 0 ? times : null);
523
+ });
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Compute segment START times (a 0-based timeline) for a session.
529
+ *
530
+ * - Re-encoded video: a uniform grid (0, segDur, 2·segDur, …) — ffmpeg's fixed
531
+ * GOP makes the real cuts land exactly here.
532
+ * - Copied video: the source's real keyframes, normalized to 0 (start time
533
+ * subtracted) and greedily grouped to ≥ segDur — these are exactly where
534
+ * `-hls_time segDur` cuts a copied stream, so the playlist matches reality.
535
+ *
536
+ * The returned array starts at 0 and ends at `durationSeconds` (so segment i
537
+ * spans `[boundaries[i], boundaries[i+1])`). Falls back to a uniform grid when
538
+ * keyframes are unavailable.
539
+ *
540
+ * @param {{ transcodeVideo: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
541
+ * @returns {number[]}
542
+ */
543
+ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, keyframeTimes, startTime }) {
544
+ const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
545
+ const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
546
+ const uniform = () => {
547
+ const boundaries = [];
548
+ for (let t = 0; t < total - 0.001; t += step) {
549
+ boundaries.push(Number(t.toFixed(6)));
550
+ }
551
+ boundaries.push(total);
552
+ return boundaries;
553
+ };
554
+ if (transcodeVideo || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
555
+ return uniform();
556
+ }
557
+ const base = Number.isFinite(startTime) ? startTime : 0;
558
+ const norm = keyframeTimes
559
+ .map((t) => t - base)
560
+ .filter((t) => t >= -0.001 && t < total - 0.05)
561
+ .sort((a, b) => a - b);
562
+ const boundaries = [0];
563
+ for (const kf of norm) {
564
+ if (kf >= boundaries[boundaries.length - 1] + step - 0.05) {
565
+ boundaries.push(Number(kf.toFixed(6)));
566
+ }
567
+ }
568
+ boundaries.push(total);
569
+ // Guard against a degenerate probe (e.g. a single keyframe) — fall back.
570
+ return boundaries.length >= 2 ? boundaries : uniform();
571
+ }
572
+
573
+ /**
574
+ * The largest keyframe time that does not exceed `target`, from a SORTED
575
+ * (ascending) array of keyframe times such as {@link probeVideoKeyframeTimes}
576
+ * returns. Null when `target` is before the first keyframe or the array is
577
+ * empty the caller then falls back to its unsnapped target.
578
+ *
579
+ * @param {number[]} keyframeTimes - Sorted ascending.
580
+ * @param {number} target
581
+ * @returns {number | null}
582
+ */
583
+ function nearestKeyframeAtOrBefore(keyframeTimes, target) {
584
+ let result = null;
585
+ for (const time of keyframeTimes) {
586
+ if (time > target) {
587
+ break;
588
+ }
589
+ result = time;
590
+ }
591
+ return result;
592
+ }
593
+
594
+ function isWarmupTimeoutError(error) {
595
+ if (!(error instanceof Error)) {
596
+ return false;
597
+ }
598
+ return error.message === "HLS playlist is still warming up.";
599
+ }
600
+
601
+ function normalizeLogFileName(fileName, fileIndex) {
602
+ const fallback = `file#${fileIndex}`;
603
+ if (typeof fileName !== "string") {
604
+ return fallback;
605
+ }
606
+ const value = fileName.trim();
607
+ if (value.length === 0) {
608
+ return fallback;
609
+ }
610
+ return value;
611
+ }
612
+
613
+ /**
614
+ * @typedef {Object} HlsSessionManagerOptions
615
+ * @property {boolean} enabled - Whether HLS transcoding is enabled.
616
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
617
+ * @property {string} localBindHost - Host the proxy HTTP server is bound to.
618
+ * @property {number} localPort - Port the proxy HTTP server is listening on.
619
+ * @property {number} [segmentDurationSec] - HLS segment length in seconds.
620
+ * @property {number} [sessionTtlMs] - Session idle TTL in milliseconds.
621
+ * @property {number} [startupWaitMs] - Max time to wait for the first playlist file.
622
+ * @property {string} [segmentFormatId] - Output container: "fmp4" (default)
623
+ * or "mpegts". See `./segment-formats/index.js`.
624
+ */
625
+
626
+ /**
627
+ * @typedef {Object} HlsSession
628
+ * @property {string} id - UUID of the session.
629
+ * @property {string} sourceMapKey - Cache key combining source + transcode settings.
630
+ * @property {string} fileName - Display name of the file being transcoded.
631
+ * @property {string} dirPath - Temp directory containing HLS output.
632
+ * @property {"starting" | "ready" | "failed" | "disposed"} state
633
+ * @property {number} startedAt - Unix ms timestamp when the session was created.
634
+ * @property {number} lastAccessedAt - Unix ms timestamp of the last consumer access.
635
+ * @property {import("node:child_process").ChildProcess} ffmpeg
636
+ * @property {string} lastError
637
+ * @property {Set<string>} consumers - Consumer IDs currently using this session.
638
+ * @property {object} progress - Live progress metrics updated from ffmpeg stdout.
639
+ * @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
640
+ * lets a call that awaited the previous ffmpeg's exit detect it was superseded
641
+ * by a newer restart request and abort instead of spawning a second process.
642
+ * @property {number[] | null} keyframeTimes - Real source keyframe times
643
+ * (sorted seconds), or null when the probe failed/timed out. Used to snap a
644
+ * source seek onto a known-valid position (see #startEncodeRun).
645
+ * @property {number} seekFailureTarget - Segment index of the last fast seek
646
+ * failure, for the consecutive-failure circuit breaker (see MAX_SEEK_FAILURES).
647
+ * @property {number} seekFailureCount - Consecutive fast failures at seekFailureTarget.
648
+ */
649
+
650
+ /**
651
+ * Manages HLS transcode sessions backed by ffmpeg child processes.
652
+ *
653
+ * One session is created per unique (source, fileIndex, transcode settings)
654
+ * combination. Sessions are reused across consumers and are automatically
655
+ * expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
656
+ */
657
+ export class HlsSessionManager {
658
+ /**
659
+ * @param {HlsSessionManagerOptions} options
660
+ */
661
+ constructor({
662
+ enabled,
663
+ ffmpegBin,
664
+ localBindHost,
665
+ localPort,
666
+ segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
667
+ sessionTtlMs = DEFAULT_SESSION_TTL_MS,
668
+ startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
669
+ videoEncoder = null,
670
+ softwarePresetBenchmark = null,
671
+ getSourceStats = null,
672
+ tonemapSupported = false,
673
+ getCachedMediaInfo = null,
674
+ segmentFormatId = undefined
675
+ }) {
676
+ this.enabled = Boolean(enabled);
677
+ this.ffmpegBin = ffmpegBin;
678
+ // Output container (fMP4/CMAF or MPEG-TS). Everything container-specific —
679
+ // muxer args, file naming, playlist header, per-segment correction — lives
680
+ // in this module; nothing here branches on the format.
681
+ this.segmentFormat = resolveSegmentFormat(segmentFormatId);
682
+ // Optional accessor for media info the playback planner already probed for
683
+ // (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
684
+ this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
685
+ // Optional async accessor for a source's live download stats, used by the
686
+ // realtime budget to tell a CPU limit from a download-starved input:
687
+ // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
688
+ this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
689
+ // Detected H.264 encoder descriptor (hardware or software). Defaults to
690
+ // software libx264 when no detection result is supplied. May be downgraded
691
+ // to software at runtime if a hardware encode fails.
692
+ this.videoEncoder = videoEncoder ?? softwareDescriptor();
693
+ // Per-preset software encode throughput (pixels/sec) measured at startup,
694
+ // used to pick the best preset per stream. Null when unavailable (hardware
695
+ // encoder, or benchmark skipped/failed).
696
+ this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
697
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
698
+ // Gates the tonemap chain for HDR sources on the software path.
699
+ this.tonemapSupported = Boolean(tonemapSupported);
700
+ this.segmentDurationSec = segmentDurationSec;
701
+ this.sessionTtlMs = sessionTtlMs;
702
+ this.startupWaitMs = startupWaitMs;
703
+ this.localBaseUrl = buildHttpBaseUrl(localBindHost, localPort);
704
+ this.sessionsById = new Map();
705
+ this.sessionIdBySource = new Map();
706
+ this.cleanupTimer = setInterval(() => {
707
+ void this.cleanupExpired();
708
+ }, CLEANUP_INTERVAL_MS);
709
+ this.cleanupTimer.unref();
710
+ // Realtime-budget monitor: only meaningful for the software encoder with a
711
+ // benchmark (the only path that can pick/step resolution). Cheap no-op scan
712
+ // otherwise.
713
+ this.budgetTimer = setInterval(() => {
714
+ void this.#enforceRealtimeBudget();
715
+ }, BUDGET_CHECK_INTERVAL_MS);
716
+ this.budgetTimer.unref();
717
+ }
718
+
719
+ /**
720
+ * Return an existing HLS session for the given source/settings, or create
721
+ * one by spawning a new ffmpeg process.
722
+ *
723
+ * Throws with `error.code === "TRANSCODE_DISABLED"` when transcoding is
724
+ * disabled on this proxy instance.
725
+ *
726
+ * @param {object} options
727
+ * @param {string} options.sourceKey - Registry source key.
728
+ * @param {number} options.fileIndex - Zero-based file index in the torrent.
729
+ * @param {boolean} [options.transcodeVideo=false]
730
+ * @param {boolean} [options.transcodeAudio=false]
731
+ * @param {string} [options.consumerId=""] - Caller ID for reference counting.
732
+ * @param {string} [options.fileName=""] - Display name for log output.
733
+ * @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
734
+ * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
735
+ * @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
736
+ * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
737
+ * @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
738
+ * @returns {Promise<HlsSession>}
739
+ */
740
+ async createOrGetSession({
741
+ sourceKey,
742
+ fileIndex,
743
+ transcodeVideo = false,
744
+ transcodeAudio = false,
745
+ consumerId = "",
746
+ fileName = "",
747
+ targetWidth = 0,
748
+ targetHeight = 0,
749
+ startPositionSeconds = 0,
750
+ audioTrackIndex = 0,
751
+ manualQuality = false
752
+ }) {
753
+ if (!this.enabled) {
754
+ const error = new Error("Audio transcoding is disabled on this proxy.");
755
+ error.code = "TRANSCODE_DISABLED";
756
+ throw error;
757
+ }
758
+
759
+ const normalizedTargetWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0;
760
+ const normalizedTargetHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0;
761
+ // Round seek position to the nearest 10 s so that two consumers seeking
762
+ // to similar positions can share the same ffmpeg session.
763
+ const normalizedStartPosition =
764
+ Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
765
+ ? Math.round(startPositionSeconds / 10) * 10
766
+ : 0;
767
+ const normalizedAudioTrack =
768
+ Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
769
+ const forceManualQuality = manualQuality === true && transcodeVideo;
770
+ const sourceMapKey = [
771
+ sourceKey,
772
+ String(fileIndex),
773
+ transcodeVideo ? "video" : "audio",
774
+ transcodeAudio ? "a1" : "a0",
775
+ `t${normalizedAudioTrack}`,
776
+ String(normalizedTargetWidth),
777
+ String(normalizedTargetHeight),
778
+ forceManualQuality ? "q-manual" : "q-auto",
779
+ String(normalizedStartPosition)
780
+ ].join(":");
781
+ const existingId = this.sessionIdBySource.get(sourceMapKey);
782
+ if (existingId) {
783
+ const existing = this.sessionsById.get(existingId);
784
+ if (existing && existing.state !== "failed") {
785
+ existing.fileName = normalizeLogFileName(fileName, fileIndex);
786
+ if (consumerId) {
787
+ existing.consumers.add(consumerId);
788
+ }
789
+ existing.lastAccessedAt = Date.now();
790
+ try {
791
+ await this.waitUntilReady(existing);
792
+ } catch (error) {
793
+ if (!isWarmupTimeoutError(error)) {
794
+ throw error;
795
+ }
796
+ // Keep session reusable while ffmpeg is still warming up.
797
+ }
798
+ return existing;
799
+ }
800
+ }
801
+
802
+ const sessionId = randomUUID();
803
+ const createEntryMs = Date.now();
804
+ const sessionDir = createSessionDirPath(sessionId);
805
+ await mkdir(sessionDir, { recursive: true });
806
+ const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
807
+ inputUrl.searchParams.set("sourceKey", sourceKey);
808
+ inputUrl.searchParams.set("fileIndex", String(fileIndex));
809
+
810
+ // Media info (duration/resolution/fps/startTime/HDR) up front, so we can
811
+ // serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
812
+ // duration and a fully seekable timeline before a single segment exists.
813
+ // Reuse the planner's probe when it is available and complete — the plan
814
+ // request just ran the same ffmpeg scan over the same input. Fall back to
815
+ // a fresh probe otherwise (proxy restarted between plan and session, or a
816
+ // critical field is missing).
817
+ const mediaInfoStartMs = Date.now();
818
+ const cachedMediaInfo = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
819
+ const cachedUsable =
820
+ cachedMediaInfo &&
821
+ Number.isFinite(cachedMediaInfo.durationSeconds) &&
822
+ cachedMediaInfo.durationSeconds > 0 &&
823
+ Number.isFinite(cachedMediaInfo.width) &&
824
+ cachedMediaInfo.width > 0 &&
825
+ Number.isFinite(cachedMediaInfo.height) &&
826
+ cachedMediaInfo.height > 0;
827
+ const mediaInfo = cachedUsable
828
+ ? cachedMediaInfo
829
+ : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
830
+ const mediaInfoMs = Date.now() - mediaInfoStartMs;
831
+ const mediaInfoSource = cachedUsable ? "cached" : "probed";
832
+ const durationSeconds = mediaInfo.durationSeconds;
833
+ const sourceWidth = mediaInfo.width;
834
+ const sourceHeight = mediaInfo.height;
835
+ const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
836
+ // Tone-map an HDR source to SDR only when re-encoding video on the software
837
+ // path and this ffmpeg has the filters. Hardware encoders keep their own
838
+ // (untone-mapped) path for now; when unavailable, HDR falls back to a plain
839
+ // 8-bit convert (washed-out but playable).
840
+ const applyTonemap =
841
+ transcodeVideo === true &&
842
+ mediaInfo.isHdr === true &&
843
+ this.tonemapSupported &&
844
+ this.videoEncoder?.kind === "software";
845
+ // Output frame rate inherited from the source (integer, capped) so 25/30
846
+ // fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
847
+ // relationship exact; time-based-keyframe encoders just use it as the rate.
848
+ const outputFps = chooseOutputFps(mediaInfo.fps);
849
+ const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
850
+ const logName = normalizeLogFileName(fileName, fileIndex);
851
+ if (!hasDuration) {
852
+ logger.warn(
853
+ `transcode ${sessionId}: could not probe duration; falling back to ` +
854
+ `ffmpeg-managed (growing) playlist for "${logName}"`
855
+ );
856
+ }
857
+
858
+ // For the video-copy path we cannot insert keyframes, so the playlist's
859
+ // segment boundaries must match the source's real keyframes (otherwise the
860
+ // player sees gaps on seek). Re-encoded video uses a uniform grid for
861
+ // segment boundaries instead (its fixed GOP makes the cuts land there —
862
+ // computeSegmentBoundaries ignores keyframeTimes when transcodeVideo).
863
+ //
864
+ // But the probe is ALSO used for something both branches need: choosing a
865
+ // SOURCE seek position ffmpeg can actually land on. `-ss` before `-i` trusts
866
+ // the container's own on-the-fly seek/index, which for some containers
867
+ // (observed: AVI with VBR MP3 audio) can point at a position with no valid
868
+ // frame boundary at all — ffmpeg then fails outright ("Seek failed" /
869
+ // "Header missing"), not just imprecisely. Snapping the seek to the nearest
870
+ // KNOWN real keyframe (see #startEncodeRun) avoids that. So probe for both
871
+ // branches; on failure both fall back to their current behaviour (uniform
872
+ // grid for boundaries, raw target for seeking) no regression.
873
+ let keyframeTimes = null;
874
+ let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
875
+ if (hasDuration && !transcodeVideo) {
876
+ // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
877
+ // boundaries (the playlist itself), so this MUST block session creation —
878
+ // an incorrect playlist is worse than a slower start. Short timeout: mp4
879
+ // keyframes come from the moov index (fast); containers that force a full
880
+ // packet scan time out and fall back to a uniform grid, so this never adds
881
+ // more than ~6 s to session start.
882
+ const keyframeStartMs = Date.now();
883
+ keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
884
+ keyframeMs = Date.now() - keyframeStartMs;
885
+ if (!keyframeTimes) {
886
+ logger.warn(
887
+ `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
888
+ `for "${logName}" (seek precision may be reduced)`
889
+ );
890
+ }
891
+ } else if (hasDuration && transcodeVideo) {
892
+ // Re-encode path: keyframeTimes are ONLY used to snap a LATER seek (see
893
+ // #startEncodeRun) segment boundaries stay on the uniform grid either
894
+ // way. So this does NOT need to block session creation / the first
895
+ // segment's start. Run it in the background with a FULL budget instead of
896
+ // the 6 s cap: AVI-class containers need a full packet scan, which 6 s can
897
+ // never afford without delaying playback start — that starved budget is
898
+ // exactly why the probe kept missing on the container where the seek bug
899
+ // was field-diagnosed. #startEncodeRun reads session.keyframeTimes fresh
900
+ // on every call, so a seek that happens AFTER this finishes picks it up
901
+ // automatically; one that happens before falls back to the existing
902
+ // circuit breaker as a safety net (no regression either way).
903
+ keyframeMs = -2;
904
+ const backgroundStartedAt = Date.now();
905
+ void probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 25_000).then((times) => {
906
+ const liveSession = this.sessionsById.get(sessionId);
907
+ if (!liveSession || liveSession.state === "disposed") {
908
+ return; // Session gone before the probe finishednothing to update.
909
+ }
910
+ liveSession.keyframeTimes = times;
911
+ const elapsedMs = Date.now() - backgroundStartedAt;
912
+ logger.info(
913
+ times
914
+ ? `transcode ${sessionId}: background keyframe probe found ${times.length} keyframes ` +
915
+ `(${elapsedMs}ms) for "${logName}" — later seeks will snap to them`
916
+ : `transcode ${sessionId}: background keyframe probe unavailable (${elapsedMs}ms) for "${logName}" ` +
917
+ `— seeks keep using the raw target (falls back to the circuit breaker on failure)`
918
+ );
919
+ });
920
+ }
921
+ logger.info(
922
+ `cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
923
+ `keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
924
+ `create-total=${Date.now() - createEntryMs}ms`
925
+ );
926
+ const segmentBoundaries = hasDuration
927
+ ? computeSegmentBoundaries({
928
+ transcodeVideo,
929
+ durationSeconds,
930
+ segDur: this.segmentDurationSec,
931
+ keyframeTimes,
932
+ startTime: sourceStartTime
933
+ })
934
+ : [];
935
+ const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
936
+ const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
937
+
938
+ // Realtime budget (software encoder): pick the output resolution + libx264
939
+ // preset this host can encode faster than realtime. On a weak host this
940
+ // downscales below the client target (the orientation-independent ceiling)
941
+ // instead of dropping into sub-realtime playback. Null for hardware
942
+ // encoders or when the source size / benchmark is unavailable — the encode
943
+ // then keeps the client target box and buildVideoArgs's default preset.
944
+ //
945
+ // Manual quality bypasses the budget entirely: the user forced a specific
946
+ // resolution, so encode exactly that box (capped to source by the scale
947
+ // filter) with the default preset, and the runtime downswitch is skipped
948
+ // for the session (budgetLadder stays null).
949
+ const encodeBudget = forceManualQuality
950
+ ? null
951
+ : this.#chooseEncodeBudget({
952
+ transcodeVideo,
953
+ targetWidth: normalizedTargetWidth,
954
+ targetHeight: normalizedTargetHeight,
955
+ sourceWidth,
956
+ sourceHeight,
957
+ outputFps
958
+ });
959
+ const softwarePreset = encodeBudget?.preset ?? null;
960
+ // Effective encode box: the budget's downscaled resolution when applied,
961
+ // otherwise the client target (0 = keep source, handled by buildVideoArgs).
962
+ const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
963
+ const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
964
+
965
+ const session = {
966
+ id: sessionId,
967
+ sourceMapKey,
968
+ fileName: logName,
969
+ dirPath: sessionDir,
970
+ state: "starting",
971
+ startedAt: Date.now(),
972
+ lastAccessedAt: Date.now(),
973
+ ffmpeg: null,
974
+ encodeRunGeneration: 0,
975
+ lastError: "",
976
+ // Cold-start timing: entry timestamp + a once-guard so the first servable
977
+ // segment logs its latency exactly once.
978
+ createEntryMs,
979
+ firstSegmentLogged: false,
980
+ consumers: new Set(consumerId ? [consumerId] : []),
981
+ // Transcode parameters retained so the encode run can be restarted at an
982
+ // arbitrary segment when the player seeks (server-side seeking).
983
+ sourceKey,
984
+ fileIndex,
985
+ transcodeVideo,
986
+ transcodeAudio,
987
+ audioTrackIndex: normalizedAudioTrack,
988
+ outputFps,
989
+ // Client-requested target box (the orientation-independent ceiling). Kept
990
+ // for the session key and reference; the actual encode uses encodeWidth/
991
+ // encodeHeight, which the realtime budget may have downscaled below this.
992
+ targetWidth: normalizedTargetWidth,
993
+ targetHeight: normalizedTargetHeight,
994
+ // Effective encode resolution handed to ffmpeg (budget-selected on weak
995
+ // software hosts, else the client target). 0 = keep source.
996
+ encodeWidth,
997
+ encodeHeight,
998
+ // Whether to insert the HDR→SDR tone-map chain (software path only).
999
+ applyTonemap,
1000
+ // Realtime-budget runtime state (software encoder only). The ladder is the
1001
+ // resolution rungs from the ceiling down; rungIndex is the current rung.
1002
+ // The monitor steps rungIndex down when the encoder is sustainedly
1003
+ // CPU-bound and restarts ffmpeg at the current segment.
1004
+ budgetLadder: encodeBudget?.ladder ?? null,
1005
+ budgetRungIndex: Number.isInteger(encodeBudget?.rungIndex) ? encodeBudget.rungIndex : 0,
1006
+ budgetDownshifts: 0,
1007
+ budgetSlowSince: 0,
1008
+ budgetLastActionAt: 0,
1009
+ // Latest viewer link report ({ linkMbps, bufferedAheadSec, at }) and the
1010
+ // link-deficit slow window (mirrors budgetSlowSince for the CPU path).
1011
+ netReport: null,
1012
+ linkSlowSince: 0,
1013
+ sourceWidth,
1014
+ sourceHeight,
1015
+ // Container start time (seconds); subtracted on the copy path so the
1016
+ // output timeline is 0-based even when the source starts at e.g. 0.1 s.
1017
+ sourceStartTime,
1018
+ // Chosen libx264 preset for this stream (software only), or null.
1019
+ softwarePreset,
1020
+ inputUrl: inputUrl.toString(),
1021
+ // VOD playlist bookkeeping.
1022
+ useSyntheticPlaylist: hasDuration,
1023
+ totalDurationSeconds: hasDuration ? durationSeconds : null,
1024
+ // Segment start times (0-based). Uniform grid for re-encoded video; real
1025
+ // keyframe positions for copied video. Drives the playlist and seeking.
1026
+ segmentBoundaries,
1027
+ segmentCount,
1028
+ // Real source keyframe times (sorted seconds), or null when the probe
1029
+ // failed/timed out. Used by #startEncodeRun to snap a source seek onto a
1030
+ // KNOWN valid position instead of trusting the container's own on-the-fly
1031
+ // seek at an arbitrary target — see the probe call above for why.
1032
+ keyframeTimes,
1033
+ playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries) : "",
1034
+ // Segment index the current ffmpeg run started producing from.
1035
+ encodeStartIndex: 0,
1036
+ // Guards against repeatedly restarting to the same seek position.
1037
+ pendingRestartIndex: -1,
1038
+ // Timestamp of the last encode (re)start, for the restart cooldown.
1039
+ lastRestartAt: 0,
1040
+ // Seek debounce: pending settle timer, the far segment index to restart
1041
+ // at once the burst settles, and the timestamp of the burst's first far
1042
+ // request (for the SEEK_SETTLE_MAX_MS cap).
1043
+ seekSettleTimer: null,
1044
+ seekTarget: null,
1045
+ seekFirstFarAt: 0,
1046
+ // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
1047
+ // seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
1048
+ // survives past the fast-fail window. See the exit handler in
1049
+ // #wireEncodeProcess and MAX_SEEK_FAILURES.
1050
+ seekFailureTarget: -1,
1051
+ seekFailureCount: 0,
1052
+ progress: {
1053
+ state: "starting",
1054
+ processedSeconds: 0,
1055
+ startPositionSeconds: 0,
1056
+ totalSeconds: hasDuration ? durationSeconds : null,
1057
+ percent: null,
1058
+ remainingSeconds: hasDuration ? durationSeconds : null,
1059
+ speed: "",
1060
+ updatedAt: Date.now(),
1061
+ lastLoggedAt: 0
1062
+ }
1063
+ };
1064
+ this.sessionsById.set(sessionId, session);
1065
+ this.sessionIdBySource.set(sourceMapKey, sessionId);
1066
+
1067
+ logger.info(
1068
+ `transcode ${sessionId} start "${logName}" ` +
1069
+ `video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
1070
+ `audio=${transcodeAudio ? "aac" : "copy"} ` +
1071
+ // Branch tag for log correlation: A = video re-encode (fixed GOP, grid
1072
+ // aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
1073
+ `branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
1074
+ `seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
1075
+ `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
1076
+ // Effective encode resolution: budget-on (auto downscale from the
1077
+ // ceiling), manual (user-forced, budget off), or unset (keep source).
1078
+ `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
1079
+ `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
1080
+ // HDR source and whether the tone-map chain was applied (vs washed-out
1081
+ // fallback when the filters are missing or on a hardware encoder).
1082
+ `${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
1083
+ `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
1084
+ `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
1085
+ );
1086
+
1087
+ await this.#startEncodeRun(session, 0);
1088
+
1089
+ try {
1090
+ await this.waitUntilReady(session);
1091
+ return session;
1092
+ } catch (error) {
1093
+ if (session.state === "failed") {
1094
+ await this.disposeSession(session.id);
1095
+ throw error;
1096
+ }
1097
+ // Do not fail session creation on warmup timeout; the synthetic playlist
1098
+ // is already available and segments appear as ffmpeg produces them.
1099
+ return session;
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * Build a complete VOD HLS playlist for the full media duration.
1105
+ *
1106
+ * The playlist lists every segment up-front and is terminated with
1107
+ * `#EXT-X-ENDLIST`, so the player knows the total duration and can seek to
1108
+ * any position immediately even before the corresponding segment has been
1109
+ * transcoded. Segments are produced on demand (see {@link getFileStream}).
1110
+ *
1111
+ * @param {number[]} boundaries - Segment start times (0-based); segment i
1112
+ * spans `[boundaries[i], boundaries[i+1])`.
1113
+ * @returns {string}
1114
+ */
1115
+ #buildVodPlaylist(boundaries) {
1116
+ const count = Math.max(0, boundaries.length - 1);
1117
+ let maxDuration = 0;
1118
+ for (let index = 0; index < count; index += 1) {
1119
+ const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
1120
+ if (duration > maxDuration) {
1121
+ maxDuration = duration;
1122
+ }
1123
+ }
1124
+ const lines = [
1125
+ "#EXTM3U",
1126
+ // The container decides the minimum version (fMP4 + `#EXT-X-MAP` needs 7,
1127
+ // MPEG-TS is fine at 3).
1128
+ `#EXT-X-VERSION:${this.segmentFormat.playlistVersion}`,
1129
+ `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
1130
+ "#EXT-X-MEDIA-SEQUENCE:0",
1131
+ "#EXT-X-PLAYLIST-TYPE:VOD",
1132
+ "#EXT-X-INDEPENDENT-SEGMENTS",
1133
+ // Container-specific header lines (e.g. fMP4's `#EXT-X-MAP`).
1134
+ ...this.segmentFormat.playlistHeaderLines()
1135
+ ];
1136
+ for (let index = 0; index < count; index += 1) {
1137
+ const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
1138
+ lines.push(`#EXTINF:${duration.toFixed(6)},`);
1139
+ lines.push(this.segmentFormat.segmentFileName(index));
1140
+ }
1141
+ lines.push("#EXT-X-ENDLIST");
1142
+ return `${lines.join("\n")}\n`;
1143
+ }
1144
+
1145
+ /**
1146
+ * Start time (seconds, 0-based) of segment `index`, from the session's
1147
+ * boundary table. Clamped to valid range.
1148
+ *
1149
+ * @param {HlsSession} session
1150
+ * @param {number} index
1151
+ * @returns {number}
1152
+ */
1153
+ #segmentStartTime(session, index) {
1154
+ const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
1155
+ if (boundaries.length === 0) {
1156
+ return index * this.segmentDurationSec;
1157
+ }
1158
+ const clamped = Math.max(0, Math.min(index, boundaries.length - 1));
1159
+ return boundaries[clamped];
1160
+ }
1161
+
1162
+ /**
1163
+ * Segment index whose span contains time `t` (0-based), via the boundary
1164
+ * table.
1165
+ *
1166
+ * @param {HlsSession} session
1167
+ * @param {number} t
1168
+ * @returns {number}
1169
+ */
1170
+ #segmentIndexForTime(session, t) {
1171
+ const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
1172
+ if (boundaries.length < 2) {
1173
+ return Math.max(0, Math.floor(t / this.segmentDurationSec));
1174
+ }
1175
+ // boundaries is sorted ascending; find the last boundary <= t.
1176
+ let lo = 0;
1177
+ let hi = boundaries.length - 1;
1178
+ let result = 0;
1179
+ while (lo <= hi) {
1180
+ const mid = (lo + hi) >> 1;
1181
+ if (boundaries[mid] <= t) {
1182
+ result = mid;
1183
+ lo = mid + 1;
1184
+ } else {
1185
+ hi = mid - 1;
1186
+ }
1187
+ }
1188
+ return Math.min(result, boundaries.length - 2);
1189
+ }
1190
+
1191
+ /**
1192
+ * Realtime budget (software encoder only): choose the output resolution AND
1193
+ * libx264 preset this host can encode faster than realtime, from the startup
1194
+ * benchmark. The ceiling is the client-requested box capped to the source
1195
+ * (never upscaled); the budget picks the highest resolution rung at or below
1196
+ * that ceiling that clears realtime × margin, then the best preset at that
1197
+ * resolution. On a weak host this downscales below the client target instead
1198
+ * of dropping into sub-realtime playback. Returns null when not applicable
1199
+ * (no video transcode, hardware encoder, or missing benchmark/source size)
1200
+ * the encode then keeps the ceiling resolution and the default preset.
1201
+ *
1202
+ * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
1203
+ * @returns {{ width: number, height: number, preset: string } | null}
1204
+ */
1205
+ #chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
1206
+ if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
1207
+ return null;
1208
+ }
1209
+ const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
1210
+ if (!ceiling) {
1211
+ return null;
1212
+ }
1213
+ return chooseSoftwareEncodeSettings(this.softwarePresetBenchmark, { width: ceiling.w, height: ceiling.h }, outputFps);
1214
+ }
1215
+
1216
+ /**
1217
+ * Parse ffmpeg's `speed` progress value (e.g. "0.903x", "1.6x", "N/A") into a
1218
+ * number. Returns null when it cannot be parsed (no data yet).
1219
+ *
1220
+ * @param {string} value
1221
+ * @returns {number | null}
1222
+ */
1223
+ #parseSpeed(value) {
1224
+ if (typeof value !== "string" || value.length === 0) {
1225
+ return null;
1226
+ }
1227
+ const numeric = Number.parseFloat(value);
1228
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
1229
+ }
1230
+
1231
+ /**
1232
+ * Realtime budget monitor (software encoder only). For each active
1233
+ * software-transcode session, watch the encoder's cumulative `speed`: when it
1234
+ * stays below realtime for a sustained window AND the input is not
1235
+ * download-starved (so the limit is the encoder, not the torrent), step the
1236
+ * resolution one rung down the ladder and restart the encode at the current
1237
+ * segment. Conservative: sustained window, post-action cooldown, a step cap,
1238
+ * and a resolution floor (the last ladder rung). No upswitch in v1.
1239
+ *
1240
+ * @returns {Promise<void>}
1241
+ */
1242
+ /**
1243
+ * Record the latest viewer link report for a session (adaptive bitrate).
1244
+ * Returns false for an unknown/disposed session.
1245
+ *
1246
+ * @param {string} sessionId
1247
+ * @param {{ linkMbps: number, bufferedAheadSec: number }} report
1248
+ * @returns {boolean}
1249
+ */
1250
+ recordNetReport(sessionId, { linkMbps, bufferedAheadSec }) {
1251
+ const session = this.sessionsById.get(sessionId);
1252
+ if (!session || session.state === "disposed") {
1253
+ return false;
1254
+ }
1255
+ session.netReport = { linkMbps, bufferedAheadSec, at: Date.now() };
1256
+ return true;
1257
+ }
1258
+
1259
+ /**
1260
+ * Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
1261
+ * segment files (the newest file may still be being written and is
1262
+ * excluded). Transcode sessions only — their segment grid is uniform, so
1263
+ * bytes / (count × segDur) is exact. Returns null when there is not enough
1264
+ * material to measure.
1265
+ *
1266
+ * @param {HlsSession} session
1267
+ * @returns {Promise<number | null>}
1268
+ */
1269
+ async #observedStreamMbps(session) {
1270
+ let names;
1271
+ try {
1272
+ names = await readdir(session.dirPath);
1273
+ } catch {
1274
+ return null;
1275
+ }
1276
+ const indices = [];
1277
+ for (const name of names) {
1278
+ const index = this.segmentFormat.segmentIndexFromName(name);
1279
+ if (index >= 0) {
1280
+ indices.push(index);
1281
+ }
1282
+ }
1283
+ if (indices.length < 3) {
1284
+ return null; // need ≥2 completed segments after dropping the newest
1285
+ }
1286
+ indices.sort((a, b) => a - b);
1287
+ const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
1288
+ let bytes = 0;
1289
+ try {
1290
+ for (const index of completed) {
1291
+ const st = await stat(path.join(session.dirPath, this.segmentFormat.segmentFileName(index)));
1292
+ bytes += st.size;
1293
+ }
1294
+ } catch {
1295
+ return null; // a segment vanished mid-measure (seek-restart cleanup)
1296
+ }
1297
+ return (bytes * 8) / (completed.length * this.segmentDurationSec) / 1e6;
1298
+ }
1299
+
1300
+ /**
1301
+ * Viewer-link deficit check for one session (adaptive bitrate, part b).
1302
+ * Mirrors the CPU slow-window pattern; shares the action cooldown and the
1303
+ * downshift machinery. Returns true when a downshift was applied this tick.
1304
+ *
1305
+ * @param {HlsSession} session
1306
+ * @param {number} now
1307
+ * @returns {Promise<boolean>}
1308
+ */
1309
+ async #checkLinkBudget(session, now) {
1310
+ const report = session.netReport;
1311
+ if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
1312
+ session.linkSlowSince = 0; // no fresh data old clients / stopped reporter
1313
+ return false;
1314
+ }
1315
+ if (report.bufferedAheadSec >= LINK_LOW_BUFFER_SEC) {
1316
+ session.linkSlowSince = 0; // viewer is comfortable — nothing to fix
1317
+ return false;
1318
+ }
1319
+ const observed = await this.#observedStreamMbps(session);
1320
+ if (observed === null) {
1321
+ return false; // not enough produced material to compare against
1322
+ }
1323
+ if (report.linkMbps * LINK_SAFETY >= observed) {
1324
+ session.linkSlowSince = 0; // link keeps up
1325
+ return false;
1326
+ }
1327
+ if (session.linkSlowSince === 0) {
1328
+ session.linkSlowSince = now;
1329
+ return false;
1330
+ }
1331
+ if (now - session.linkSlowSince < LINK_SLOW_WINDOW_MS) {
1332
+ return false; // not sustained yet
1333
+ }
1334
+ if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1335
+ return false; // let the previous action settle
1336
+ }
1337
+ await this.#applyBudgetDownshift(
1338
+ session,
1339
+ `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
1340
+ "link"
1341
+ );
1342
+ session.linkSlowSince = 0;
1343
+ return true;
1344
+ }
1345
+
1346
+ async #enforceRealtimeBudget() {
1347
+ if (this.videoEncoder?.kind !== "software") {
1348
+ return;
1349
+ }
1350
+ const now = Date.now();
1351
+ for (const session of this.sessionsById.values()) {
1352
+ if (
1353
+ !session ||
1354
+ session.state === "disposed" ||
1355
+ session.state === "failed" ||
1356
+ !session.transcodeVideo ||
1357
+ !Array.isArray(session.budgetLadder) ||
1358
+ session.budgetLadder.length < 2
1359
+ ) {
1360
+ continue;
1361
+ }
1362
+ // Already at the floor or out of steps — nothing more to give.
1363
+ if (
1364
+ session.budgetRungIndex >= session.budgetLadder.length - 1 ||
1365
+ session.budgetDownshifts >= BUDGET_MAX_DOWNSHIFTS
1366
+ ) {
1367
+ continue;
1368
+ }
1369
+ // Viewer-link deficit first (adaptive bitrate): independent of encoder
1370
+ // speed — a thin cellular link starves even a faster-than-realtime
1371
+ // encode. When it acts, skip the CPU check this tick (shared cooldown
1372
+ // guards double-firing anyway).
1373
+ if (await this.#checkLinkBudget(session, now)) {
1374
+ continue;
1375
+ }
1376
+ const speed = this.#parseSpeed(session.progress?.speed);
1377
+ if (speed === null) {
1378
+ continue; // no measurement yet
1379
+ }
1380
+ if (speed >= BUDGET_SPEED_OK) {
1381
+ session.budgetSlowSince = 0; // recovered reset the slow window
1382
+ continue;
1383
+ }
1384
+ if (speed >= BUDGET_SPEED_SLOW) {
1385
+ continue; // in the hysteresis band; neither slow nor ok
1386
+ }
1387
+ // speed < BUDGET_SPEED_SLOW — track how long it has been slow.
1388
+ if (session.budgetSlowSince === 0) {
1389
+ session.budgetSlowSince = now;
1390
+ continue;
1391
+ }
1392
+ if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
1393
+ continue; // not sustained yet
1394
+ }
1395
+ if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1396
+ continue; // let the previous action settle
1397
+ }
1398
+ // Sustained sub-realtime. Only downscale if the encoder not a
1399
+ // download-starved input is the limit.
1400
+ const bound = await this.#classifyTranscodeBound(session);
1401
+ if (bound === "download") {
1402
+ logger.info(
1403
+ `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
1404
+ `"${session.fileName}"; not downscaling (torrent is the bottleneck)`
1405
+ );
1406
+ session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
1407
+ continue;
1408
+ }
1409
+ await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Decide whether a sustained sub-realtime transcode is limited by the encoder
1415
+ * (CPU) or by a download-starved input. Compares the torrent's download rate
1416
+ * with the source's average byte rate; a fully-downloaded file can never be
1417
+ * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
1418
+ * as CPU by the caller — the common case, logged as such).
1419
+ *
1420
+ * @param {HlsSession} session
1421
+ * @returns {Promise<"cpu" | "download" | "unknown">}
1422
+ */
1423
+ async #classifyTranscodeBound(session) {
1424
+ if (!this.getSourceStats) {
1425
+ return "unknown";
1426
+ }
1427
+ let stats;
1428
+ try {
1429
+ stats = await this.getSourceStats(session.sourceKey, session.fileIndex);
1430
+ } catch {
1431
+ return "unknown";
1432
+ }
1433
+ if (!stats) {
1434
+ return "unknown";
1435
+ }
1436
+ // A fully (or almost fully) downloaded file cannot be download-bound.
1437
+ if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
1438
+ return "cpu";
1439
+ }
1440
+ const duration = Number.isFinite(session.totalDurationSeconds) ? session.totalDurationSeconds : 0;
1441
+ const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
1442
+ const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
1443
+ if (duration <= 0 || length <= 0) {
1444
+ return "unknown"; // cannot compute the source byte rate
1445
+ }
1446
+ const sourceByteRate = length / duration;
1447
+ return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
1448
+ }
1449
+
1450
+ /**
1451
+ * Step a session one resolution rung down the budget ladder and restart the
1452
+ * encode at the current segment with the lighter profile.
1453
+ *
1454
+ * @param {HlsSession} session
1455
+ * @param {string} reasonText - Measurement summary for the log line.
1456
+ * @param {"cpu" | "unknown" | "link"} bound
1457
+ * @returns {Promise<void>}
1458
+ */
1459
+ async #applyBudgetDownshift(session, reasonText, bound) {
1460
+ const nextIndex = session.budgetRungIndex + 1;
1461
+ const rung = session.budgetLadder[nextIndex];
1462
+ if (!rung) {
1463
+ return;
1464
+ }
1465
+ const fps = Number.isInteger(session.outputFps) && session.outputFps > 0 ? session.outputFps : TRANSCODE_FPS;
1466
+ session.budgetRungIndex = nextIndex;
1467
+ session.budgetDownshifts += 1;
1468
+ session.budgetLastActionAt = Date.now();
1469
+ session.budgetSlowSince = 0;
1470
+ session.encodeWidth = rung.width;
1471
+ session.encodeHeight = rung.height;
1472
+ session.softwarePreset = pickSoftwarePreset(this.softwarePresetBenchmark, rung.width * rung.height * fps);
1473
+ // Restart at the current live-edge segment so the lighter profile takes over
1474
+ // from where the viewer is watching (hard-restart tier).
1475
+ const head = session.encodeStartIndex;
1476
+ const processed = Number.isFinite(session.progress?.processedSeconds)
1477
+ ? session.progress.processedSeconds
1478
+ : this.#segmentStartTime(session, head);
1479
+ const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
1480
+ const boundLabel =
1481
+ bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
1482
+ logger.info(
1483
+ `[budget] transcode ${session.id} ${boundLabel} ` +
1484
+ `${reasonText} downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
1485
+ `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
1486
+ `restart at segment #${currentSeg} "${session.fileName}"`
1487
+ );
1488
+ await this.#startEncodeRun(session, currentSeg);
1489
+ }
1490
+
1491
+ /**
1492
+ * (Re)start the ffmpeg encode run beginning at segment `startIndex`.
1493
+ *
1494
+ * Any ffmpeg process currently running for this session is terminated FIRST
1495
+ * AND ITS EXIT IS AWAITED before the replacement is spawned into the same
1496
+ * directory. This closes a real incident: a fire-and-forget SIGTERM does not
1497
+ * mean the process is dead — `ChildProcess.killed` reflects only that a
1498
+ * signal was sent, not that the process exited (ffmpeg's own blocking read of
1499
+ * our torrent-backed `/stream` input can defer signal handling for a long
1500
+ * time while starved). On a rapid sequence of seeks this left multiple
1501
+ * ffmpeg processes alive concurrently, all writing into the SAME session
1502
+ * directory — observed as `failed to rename file segment-NNNNN.m4s.tmp`
1503
+ * (a dying process racing a fresh one) and a zombie process still writing a
1504
+ * `.tmp` file ~30s after being "killed" by two LATER restarts, even after the
1505
+ * session had already been released. Multiple ffmpeg processes fighting over
1506
+ * CPU and the same files on a weak host is what a seek could get "stuck" on.
1507
+ *
1508
+ * Because this now awaits, a NEWER restart request can arrive while an OLDER
1509
+ * one is still waiting for the previous process to die. `encodeRunGeneration`
1510
+ * resolves that: each call captures its own generation number, and after the
1511
+ * await, a call whose generation was superseded aborts without spawning
1512
+ * only the LATEST requested target ever actually starts a process.
1513
+ *
1514
+ * Segment files are named with a global index (`-start_number`) so they
1515
+ * always line up with the synthetic VOD playlist regardless of where
1516
+ * encoding started this is what makes server-side seeking work.
1517
+ *
1518
+ * @param {HlsSession} session
1519
+ * @param {number} startIndex
1520
+ * @returns {Promise<void>}
1521
+ */
1522
+ async #startEncodeRun(session, startIndex) {
1523
+ const generation = ++session.encodeRunGeneration;
1524
+ const previousFfmpeg = session.ffmpeg;
1525
+ if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
1526
+ try {
1527
+ previousFfmpeg.kill("SIGTERM");
1528
+ } catch {
1529
+ // Best effort.
1530
+ }
1531
+ await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1532
+ if (!hasChildExited(previousFfmpeg)) {
1533
+ try {
1534
+ previousFfmpeg.kill("SIGKILL");
1535
+ } catch {
1536
+ // Best effort.
1537
+ }
1538
+ await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1539
+ }
1540
+ }
1541
+ // A newer restart (or disposal) won the race while we were waiting for the
1542
+ // old process to die — it either already spawned its own replacement or
1543
+ // there is nothing left to start. Do not also spawn from this stale call.
1544
+ if (session.encodeRunGeneration !== generation || session.state === "disposed") {
1545
+ return;
1546
+ }
1547
+
1548
+ const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
1549
+ // 0-based output time of this segment, from the boundary table (uniform for
1550
+ // re-encode, real keyframe for copy).
1551
+ const startSeconds = this.#segmentStartTime(session, safeIndex);
1552
+ const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
1553
+
1554
+ // Terminate any existing encode process before starting a new one. The
1555
+ // old process's exit handler no-ops because session.ffmpeg is reassigned
1556
+ // below (it checks identity).
1557
+ if (session.ffmpeg && !session.ffmpeg.killed) {
1558
+ try {
1559
+ session.ffmpeg.kill("SIGTERM");
1560
+ } catch (_error) {
1561
+ // Best effort.
1562
+ }
1563
+ }
1564
+
1565
+ // Video: re-encode only when required, using the detected encoder
1566
+ // (hardware-accelerated or software). The descriptor builds the filter +
1567
+ // codec args (including keyframe alignment on segment boundaries).
1568
+ const videoCodecArgs = session.transcodeVideo
1569
+ ? this.videoEncoder.buildVideoArgs({
1570
+ // Budget-selected encode box (may be below the client target on weak
1571
+ // software hosts); falls back to the client target for hardware.
1572
+ targetWidth: session.encodeWidth,
1573
+ targetHeight: session.encodeHeight,
1574
+ segmentDurationSec: this.segmentDurationSec,
1575
+ // Source-inherited output rate (integer, capped); descriptors that
1576
+ // use time-based keyframes just apply it as the frame rate.
1577
+ fps: session.outputFps,
1578
+ // Software-only; hardware descriptors ignore it.
1579
+ preset: session.softwarePreset ?? undefined,
1580
+ // HDR→SDR tone map (software path only; gated on filter availability).
1581
+ tonemap: session.applyTonemap === true
1582
+ })
1583
+ : ["-c:v", "copy"];
1584
+ const audioCodecArgs = session.transcodeAudio
1585
+ ? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
1586
+ : ["-c:a", "copy"];
1587
+
1588
+ const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
1589
+ // Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
1590
+ // only applies when we actually re-encode the video track.
1591
+ if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
1592
+ args.push(...this.videoEncoder.inputArgs);
1593
+ }
1594
+ // Seek position in SOURCE time. For copy we seek to the real keyframe
1595
+ // (startSeconds is already a real-keyframe offset from 0, so add back the
1596
+ // container start time); for re-encode startSeconds is a plain grid offset.
1597
+ const seekSeconds = session.transcodeVideo ? startSeconds : startSeconds + sourceStartTime;
1598
+ // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
1599
+ // keyframe (coarse, before -i safe because WE sourced it from ffprobe,
1600
+ // not the container's own on-the-fly seek/index) and trim the short
1601
+ // residual (bounded by the keyframe interval) precisely AFTER -i, which is
1602
+ // always frame-accurate regardless of -accurate_seek.
1603
+ //
1604
+ // Root cause this works around: `-accurate_seek -ss X` before -i trusts the
1605
+ // CONTAINER's own seek to land near X. For some containers (observed: AVI
1606
+ // with VBR MP3 audio) that on-the-fly seek can point at a position with no
1607
+ // valid frame boundary at all ffmpeg fails outright ("Seek failed" /
1608
+ // "Header missing"), not just imprecisely, and repeatedly so since every
1609
+ // retry re-tries the SAME bad container-computed position. A keyframe we
1610
+ // read directly from the packet list is a position ffmpeg has already
1611
+ // proven it can decode.
1612
+ const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
1613
+ ? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
1614
+ : null;
1615
+ if (snappedKeyframe !== null) {
1616
+ const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
1617
+ if (snappedKeyframe > 0) {
1618
+ args.push("-ss", String(snappedKeyframe));
1619
+ }
1620
+ args.push("-i", session.inputUrl);
1621
+ if (residualSeconds > 0) {
1622
+ args.push("-ss", String(residualSeconds));
1623
+ }
1624
+ } else {
1625
+ if (seekSeconds > 0) {
1626
+ // No keyframe map (probe failed/timed out) — fall back to the previous
1627
+ // behaviour: trust the container's own accurate seek.
1628
+ args.push("-accurate_seek", "-ss", String(seekSeconds));
1629
+ }
1630
+ args.push("-i", session.inputUrl);
1631
+ }
1632
+ if (session.transcodeVideo) {
1633
+ // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
1634
+ // segment grid; relabel output onto the original timeline so segment N
1635
+ // carries PTS = N × segmentDuration.
1636
+ if (startSeconds > 0) {
1637
+ args.push("-output_ts_offset", String(startSeconds));
1638
+ }
1639
+ } else {
1640
+ // Branch B (video copied — only audio is transcoded): we cannot insert
1641
+ // keyframes, so segments are cut at the source's own keyframes (the
1642
+ // playlist boundaries were built from those keyframes). Keep the source's
1643
+ // real timestamps (`-copyts`) so copied frames stay continuous across
1644
+ // boundaries/seeks, and shift by -startTime so the output timeline is
1645
+ // 0-based (a non-zero container start otherwise puts a hole at the very
1646
+ // beginning and desyncs audio/video). Audio is transcoded on this timeline.
1647
+ args.push("-copyts");
1648
+ if (sourceStartTime !== 0) {
1649
+ args.push("-output_ts_offset", String(-sourceStartTime));
1650
+ }
1651
+ }
1652
+ args.push(
1653
+ "-map",
1654
+ "0:v:0?",
1655
+ "-map",
1656
+ // Type-relative audio track chosen by the viewer (default 0).
1657
+ `0:a:${session.audioTrackIndex ?? 0}?`,
1658
+ ...videoCodecArgs,
1659
+ ...audioCodecArgs,
1660
+ "-f",
1661
+ "hls",
1662
+ "-hls_time",
1663
+ String(this.segmentDurationSec),
1664
+ "-hls_list_size",
1665
+ "0",
1666
+ "-hls_flags",
1667
+ "independent_segments+temp_file",
1668
+ // Container selection + segment naming, from the active format module.
1669
+ ...this.segmentFormat.muxerArgs(),
1670
+ "-start_number",
1671
+ String(safeIndex),
1672
+ // ffmpeg writes its own playlist here; we ignore it and serve the
1673
+ // synthetic VOD playlist instead (see getFileStream).
1674
+ PLAYLIST_FILE_NAME
1675
+ );
1676
+
1677
+ const ffmpeg = spawn(this.ffmpegBin, args, {
1678
+ cwd: session.dirPath,
1679
+ stdio: ["ignore", "pipe", "pipe"]
1680
+ });
1681
+ session.ffmpeg = ffmpeg;
1682
+ session.encodeStartIndex = safeIndex;
1683
+ session.pendingRestartIndex = -1;
1684
+ session.lastRestartAt = Date.now();
1685
+ session.state = session.state === "disposed" ? "disposed" : "starting";
1686
+ session.progress.state = "running";
1687
+ session.progress.processedSeconds = startSeconds;
1688
+ session.progress.startPositionSeconds = startSeconds;
1689
+ session.progress.updatedAt = Date.now();
1690
+ // Any (re)start resets the cumulative `speed` ffmpeg reports, so reset the
1691
+ // realtime-budget slow window too otherwise warm-up right after a user
1692
+ // seek could be mis-counted as sustained sub-realtime and trigger a
1693
+ // premature downscale.
1694
+ session.budgetSlowSince = 0;
1695
+
1696
+ logger.info(
1697
+ `transcode ${session.id} encode-run from segment #${safeIndex} ` +
1698
+ `(${formatSeconds(startSeconds)}) "${session.fileName}"`
1699
+ );
1700
+
1701
+ this.#wireEncodeProcess(session, ffmpeg);
1702
+ }
1703
+
1704
+ /**
1705
+ * Rebase ffmpeg's `-progress` `out_time`/`out_time_ms` onto the SOURCE
1706
+ * (absolute) timeline, so `session.progress.processedSeconds` is always
1707
+ * comparable to `session.progress.startPositionSeconds` which
1708
+ * `computeProgressMetrics` and the client's own cushion-percent/ETA math
1709
+ * both assume.
1710
+ *
1711
+ * Branch B (video copy, `-copyts`) already reports `out_time` on the
1712
+ * source's absolute timeline — no rebase needed. Branch A (video re-encode)
1713
+ * does NOT: `-output_ts_offset` (used there to relabel the MUXED output's
1714
+ * timestamps onto the absolute grid) does not affect what `-progress`
1715
+ * reports — verified empirically (a 5s clip encoded with
1716
+ * `-output_ts_offset 100` still reports `out_time` counting 0→5, not
1717
+ * 100→105). Left unrebased, `processedSeconds` jumps from the post-restart
1718
+ * placeholder (`session.progress.startPositionSeconds`, absolute) down to a
1719
+ * near-zero RELATIVE value the moment real ffmpeg progress starts flowing —
1720
+ * `processedSeconds - startPositionSeconds` then goes deeply negative,
1721
+ * clamps to 0, and the client's cushion percent/ETA reads as permanently
1722
+ * stuck at 0% for the whole run even while the encode is actively
1723
+ * producing (field-diagnosed 2026-08-01: a re-encode session logged
1724
+ * `processed=39.5 startPos=1824` at a healthy 6x realtime speed).
1725
+ *
1726
+ * @param {HlsSession} session
1727
+ * @param {number} rawSeconds - As parsed from `out_time`/`out_time_ms`.
1728
+ * @returns {number}
1729
+ */
1730
+ #toAbsoluteProcessedSeconds(session, rawSeconds) {
1731
+ if (!session.transcodeVideo) {
1732
+ return rawSeconds;
1733
+ }
1734
+ const offset = Number.isFinite(session.progress?.startPositionSeconds)
1735
+ ? session.progress.startPositionSeconds
1736
+ : 0;
1737
+ return rawSeconds + offset;
1738
+ }
1739
+
1740
+ /**
1741
+ * Wire stdout (progress), stderr (errors) and exit handlers for an ffmpeg
1742
+ * encode process. Handlers no-op when the process has been superseded by a
1743
+ * later encode run (identity check against `session.ffmpeg`).
1744
+ *
1745
+ * @param {HlsSession} session
1746
+ * @param {import("node:child_process").ChildProcess} ffmpeg
1747
+ * @returns {void}
1748
+ */
1749
+ #wireEncodeProcess(session, ffmpeg) {
1750
+ ffmpeg.stdout.on("data", (chunk) => {
1751
+ const lines = String(chunk).split(/\r?\n/);
1752
+ for (const line of lines) {
1753
+ const normalized = line.trim();
1754
+ if (!normalized) {
1755
+ continue;
1756
+ }
1757
+ const separator = normalized.indexOf("=");
1758
+ if (separator <= 0) {
1759
+ continue;
1760
+ }
1761
+ const key = normalized.slice(0, separator);
1762
+ const value = normalized.slice(separator + 1);
1763
+
1764
+ if (key === "out_time_ms") {
1765
+ const numeric = Number(value);
1766
+ if (Number.isFinite(numeric) && numeric >= 0) {
1767
+ session.progress.processedSeconds = this.#toAbsoluteProcessedSeconds(session, numeric / MICROSECONDS_PER_SECOND);
1768
+ }
1769
+ } else if (key === "out_time") {
1770
+ const parsed = parseFfmpegTimestamp(value);
1771
+ if (parsed != null) {
1772
+ session.progress.processedSeconds = this.#toAbsoluteProcessedSeconds(session, parsed);
1773
+ }
1774
+ } else if (key === "speed") {
1775
+ session.progress.speed = value;
1776
+ } else if (key === "progress") {
1777
+ session.progress.state = value === "end" ? "ready" : "running";
1778
+ }
1779
+ const metrics = computeProgressMetrics(
1780
+ session.progress.processedSeconds,
1781
+ session.progress.totalSeconds,
1782
+ session.progress.startPositionSeconds
1783
+ );
1784
+ session.progress.percent = metrics.percent;
1785
+ session.progress.remainingSeconds = metrics.remainingSeconds;
1786
+ session.progress.updatedAt = Date.now();
1787
+ const shouldLog =
1788
+ session.progress.percent != null &&
1789
+ session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
1790
+ if (shouldLog) {
1791
+ session.progress.lastLoggedAt = session.progress.updatedAt;
1792
+ logger.info(
1793
+ `transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
1794
+ `(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
1795
+ ` speed=${session.progress.speed || "n/a"}`
1796
+ );
1797
+ }
1798
+ }
1799
+ });
1800
+
1801
+ ffmpeg.stderr.on("data", (chunk) => {
1802
+ const line = String(chunk).trim();
1803
+ if (line.length > 0) {
1804
+ session.lastError = line;
1805
+ logger.warn(`ffmpeg ${session.id}: ${line}`);
1806
+ }
1807
+ });
1808
+
1809
+ ffmpeg.on("error", (error) => {
1810
+ if (session.ffmpeg !== ffmpeg) {
1811
+ return;
1812
+ }
1813
+ session.state = "failed";
1814
+ session.lastError = error instanceof Error ? error.message : String(error);
1815
+ session.progress.state = "failed";
1816
+ session.progress.updatedAt = Date.now();
1817
+ logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
1818
+ });
1819
+
1820
+ ffmpeg.on("exit", (code, signal) => {
1821
+ // Ignore the exit of a process that was superseded by a seek-restart.
1822
+ if (session.ffmpeg !== ffmpeg) {
1823
+ return;
1824
+ }
1825
+ if (session.state === "disposed") {
1826
+ return;
1827
+ }
1828
+ if (code === 0) {
1829
+ session.state = "ready";
1830
+ session.progress.state = "ready";
1831
+ session.progress.updatedAt = Date.now();
1832
+ logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
1833
+ return;
1834
+ }
1835
+ if (!session.lastError) {
1836
+ session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
1837
+ }
1838
+ // Runtime safety net: if a hardware encode fails, downgrade this proxy to
1839
+ // software encoding for all sessions and restart this one, so playback is
1840
+ // never permanently broken by a hardware/driver issue.
1841
+ if (session.transcodeVideo && this.videoEncoder.kind !== "software") {
1842
+ const failedEncoder = this.videoEncoder.name;
1843
+ this.videoEncoder = softwareDescriptor();
1844
+ logger.warn(
1845
+ `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
1846
+ `(${session.lastError}); falling back to software libx264 and restarting`
1847
+ );
1848
+ void this.#startEncodeRun(session, session.encodeStartIndex);
1849
+ return;
1850
+ }
1851
+ // Circuit-breaker bookkeeping: a seek-restart run that exits THIS fast
1852
+ // never did real work — it failed at the seek/open step itself, not
1853
+ // mid-stream (see SEEK_FAST_FAIL_MS). Track consecutive fast failures at
1854
+ // the SAME target so #ensureEncodingFor/#fireSettledSeek (which check
1855
+ // this below) can stop retrying instead of looping forever on a position
1856
+ // that keeps failing even with the keyframe-snapped seek.
1857
+ const elapsedMs = Date.now() - session.lastRestartAt;
1858
+ if (elapsedMs < SEEK_FAST_FAIL_MS && session.encodeStartIndex > 0) {
1859
+ if (session.seekFailureTarget === session.encodeStartIndex) {
1860
+ session.seekFailureCount += 1;
1861
+ } else {
1862
+ session.seekFailureTarget = session.encodeStartIndex;
1863
+ session.seekFailureCount = 1;
1864
+ }
1865
+ logger.warn(
1866
+ `transcode ${session.id} fast failure at segment #${session.encodeStartIndex} ` +
1867
+ `(${elapsedMs}ms) — ${session.seekFailureCount}/${MAX_SEEK_FAILURES} consecutive`
1868
+ );
1869
+ } else {
1870
+ // Real progress was made (or this was the very first run) not a
1871
+ // repeating seek failure. Reset the breaker.
1872
+ session.seekFailureTarget = -1;
1873
+ session.seekFailureCount = 0;
1874
+ }
1875
+ session.state = "failed";
1876
+ session.progress.state = "failed";
1877
+ session.progress.updatedAt = Date.now();
1878
+ logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
1879
+ });
1880
+ }
1881
+
1882
+ /**
1883
+ * Ensure the encoder is producing (or will soon produce) the requested
1884
+ * segment. If the segment is far ahead of the current encode head, or
1885
+ * behind it, restart ffmpeg at that segment (server-side seek). Requests
1886
+ * within the look-ahead window are served by waiting for the running encode.
1887
+ *
1888
+ * @param {HlsSession} session
1889
+ * @param {number} index
1890
+ * @returns {void}
1891
+ */
1892
+ #ensureEncodingFor(session, index) {
1893
+ if (!session || session.state === "disposed" || index < 0) {
1894
+ return;
1895
+ }
1896
+ const head = session.encodeStartIndex;
1897
+ // Anchor the look-ahead window on the CURRENT encode position (start index +
1898
+ // seconds already processed), not the run's start index. Otherwise a long
1899
+ // run that has encoded well past `head` would needlessly restart for a
1900
+ // request just ahead of the live edge.
1901
+ const processed = Number.isFinite(session.progress?.processedSeconds)
1902
+ ? session.progress.processedSeconds
1903
+ : this.#segmentStartTime(session, head);
1904
+ const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
1905
+ const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
1906
+ if (withinWindow) {
1907
+ return;
1908
+ }
1909
+ // Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
1910
+ // times in a row (fast failures — see #wireEncodeProcess's exit handler).
1911
+ // Stop auto-retrying it; session.state stays "failed" so getFileStream
1912
+ // reports a clean, retryable error instead of looping forever. A DIFFERENT
1913
+ // target (the viewer seeking elsewhere) is unaffected — it gets its own
1914
+ // fresh attempt budget.
1915
+ if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1916
+ return;
1917
+ }
1918
+ // Far request = a server-side seek. Do NOT restart on the first one:
1919
+ // debounce a burst of scattered requests into a single restart at the
1920
+ // position the player ended on. Record the latest target and (re)arm the
1921
+ // settle timer; the caller long-polls / the client retries meanwhile.
1922
+ session.seekTarget = index;
1923
+ if (session.seekSettleTimer) {
1924
+ clearTimeout(session.seekSettleTimer);
1925
+ } else {
1926
+ session.seekFirstFarAt = Date.now();
1927
+ }
1928
+ const waited = Date.now() - session.seekFirstFarAt;
1929
+ const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
1930
+ session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
1931
+ session.seekSettleTimer.unref?.();
1932
+ }
1933
+
1934
+ /**
1935
+ * Fire a settled server-side seek: restart the encoder once at the target
1936
+ * recorded during the settle window. Enforces the restart cooldown as a
1937
+ * floor between actual restarts (re-arming for the remainder if still
1938
+ * cooling down). No-op for a disposed session or a cleared target.
1939
+ *
1940
+ * @param {HlsSession} session
1941
+ * @returns {void}
1942
+ */
1943
+ #fireSettledSeek(session) {
1944
+ const target = session.seekTarget;
1945
+ session.seekSettleTimer = null;
1946
+ if (!session || session.state === "disposed" || target == null) {
1947
+ session.seekTarget = null;
1948
+ session.seekFirstFarAt = 0;
1949
+ return;
1950
+ }
1951
+ // Circuit breaker (defense in depth): a timer armed before the cap was hit
1952
+ // could still be pending when it was reached — do not fire the restart it
1953
+ // was going to make. See the matching check in #ensureEncodingFor.
1954
+ if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1955
+ session.seekTarget = null;
1956
+ session.seekFirstFarAt = 0;
1957
+ return;
1958
+ }
1959
+ // Minimum gap between actual restarts (the settle already collapses bursts;
1960
+ // this only guards back-to-back seeks). If still cooling down, re-arm once
1961
+ // for the remaining cooldown instead of restarting now.
1962
+ const sinceLastRestart = Date.now() - (session.lastRestartAt ?? 0);
1963
+ if (sinceLastRestart < RESTART_COOLDOWN_MS) {
1964
+ session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), RESTART_COOLDOWN_MS - sinceLastRestart);
1965
+ session.seekSettleTimer.unref?.();
1966
+ return;
1967
+ }
1968
+ session.seekTarget = null;
1969
+ session.seekFirstFarAt = 0;
1970
+ logger.info(`transcode ${session.id} seek settle restart at segment #${target}`);
1971
+ void this.#startEncodeRun(session, target);
1972
+ }
1973
+
1974
+ /**
1975
+ * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
1976
+ * header, or until the session fails, or until the startup timeout elapses.
1977
+ * Throws with message `"HLS playlist is still warming up."` on timeout.
1978
+ *
1979
+ * @param {HlsSession} session
1980
+ * @returns {Promise<void>}
1981
+ */
1982
+ async waitUntilReady(session) {
1983
+ // With a synthetic VOD playlist there is nothing to wait for: the playlist
1984
+ // is generated from the probed duration and is available immediately.
1985
+ // Individual segments are long-polled by the segment route as ffmpeg
1986
+ // produces them.
1987
+ if (session.useSyntheticPlaylist) {
1988
+ if (session.state === "failed") {
1989
+ throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
1990
+ }
1991
+ session.state = "ready";
1992
+ return;
1993
+ }
1994
+
1995
+ const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
1996
+ const deadline = Date.now() + this.startupWaitMs;
1997
+
1998
+ while (Date.now() < deadline) {
1999
+ if (session.state === "failed") {
2000
+ throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
2001
+ }
2002
+ try {
2003
+ await access(playlistPath);
2004
+ const text = await readFile(playlistPath, "utf8");
2005
+ if (text.includes("#EXTM3U")) {
2006
+ session.state = "ready";
2007
+ return;
2008
+ }
2009
+ } catch (_error) {
2010
+ // Playlist is not ready yet.
2011
+ }
2012
+ await delay(250);
2013
+ }
2014
+
2015
+ throw new Error("HLS playlist is still warming up.");
2016
+ }
2017
+
2018
+ /**
2019
+ * Open a read stream for an HLS segment or playlist file from a session.
2020
+ *
2021
+ * @param {string} sessionId
2022
+ * @param {string} fileName - Must match the playlist or segment name pattern.
2023
+ * @returns {Promise<
2024
+ * | { kind: "not-found" }
2025
+ * | { kind: "warming-up" }
2026
+ * | { kind: "failed"; message: string }
2027
+ * | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
2028
+ * >}
2029
+ */
2030
+ async getFileStream(sessionId, fileName) {
2031
+ if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName, this.segmentFormat)) {
2032
+ return { kind: "not-found" };
2033
+ }
2034
+ const session = this.sessionsById.get(sessionId);
2035
+ if (!session) {
2036
+ return { kind: "not-found" };
2037
+ }
2038
+ if (session.state === "failed") {
2039
+ return {
2040
+ kind: "failed",
2041
+ message: session.lastError || "ffmpeg failed for this transcode session."
2042
+ };
2043
+ }
2044
+ session.lastAccessedAt = Date.now();
2045
+
2046
+ // Serve the synthetic VOD playlist (full duration, terminated with
2047
+ // #EXT-X-ENDLIST) so the player gets the correct total length and a fully
2048
+ // seekable timeline up-front, independent of how far ffmpeg has encoded.
2049
+ if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
2050
+ return {
2051
+ kind: "file",
2052
+ stream: Readable.from([session.playlistText]),
2053
+ contentType: "application/vnd.apple.mpegurl",
2054
+ isPlaylist: true
2055
+ };
2056
+ }
2057
+
2058
+ // The init segment (fMP4 only; referenced by #EXT-X-MAP). Each seek-restart
2059
+ // run REWRITES it, so cache the FIRST one and always serve that — the
2060
+ // player fetches it once and never re-fetches, so it must stay stable for
2061
+ // the session's lifetime. (What that costs, and why segments must therefore
2062
+ // carry their own position, is documented in `segment-formats/mp4-boxes.js`
2063
+ // `stampSegmentStartTime`.)
2064
+ //
2065
+ // ffmpeg creates init.mp4 before it has finished writing the fMP4 header
2066
+ // boxes into it (unlike segments, its write is not gated behind an atomic
2067
+ // rename), so a read can race a moment where the file EXISTS but is still
2068
+ // EMPTY. Root cause of a real incident: that empty read used to be cached
2069
+ // as `session.initBytes` — a zero-length Buffer is still a truthy object,
2070
+ // so `if (session.initBytes)` treated it as "already resolved" and served
2071
+ // the empty file for the rest of the session's life, permanently breaking
2072
+ // playback (hls.js can never initialize its SourceBuffer from an empty
2073
+ // init segment) while the transcode itself kept encoding normally. Guard
2074
+ // on non-empty content on both the cache check and the fresh read, so an
2075
+ // empty read is treated as not-yet-ready and the caller's long-poll keeps
2076
+ // retrying until ffmpeg has actually written the header.
2077
+ const { initFileName } = this.segmentFormat;
2078
+ if (initFileName !== null && fileName === initFileName) {
2079
+ if (session.initBytes && session.initBytes.length > 0) {
2080
+ return {
2081
+ kind: "file",
2082
+ stream: Readable.from([session.initBytes]),
2083
+ contentType: this.segmentFormat.initContentType,
2084
+ isPlaylist: false
2085
+ };
2086
+ }
2087
+ try {
2088
+ const bytes = await readFile(path.join(session.dirPath, initFileName));
2089
+ if (bytes.length === 0) {
2090
+ return { kind: "warming-up" };
2091
+ }
2092
+ session.initBytes = bytes;
2093
+ return {
2094
+ kind: "file",
2095
+ stream: Readable.from([bytes]),
2096
+ contentType: this.segmentFormat.initContentType,
2097
+ isPlaylist: false
2098
+ };
2099
+ } catch {
2100
+ // Not produced yet — the encode run started at session creation writes
2101
+ // it early; the caller long-polls until it appears.
2102
+ return { kind: "warming-up" };
2103
+ }
2104
+ }
2105
+
2106
+ const filePath = path.join(session.dirPath, fileName);
2107
+ const isPlaylist = fileName === PLAYLIST_FILE_NAME;
2108
+ try {
2109
+ await access(filePath);
2110
+ // Cold-start: log the first servable SEGMENT of this session exactly once
2111
+ // — the time from session-create entry to a playable first segment.
2112
+ if (!isPlaylist && !session.firstSegmentLogged) {
2113
+ session.firstSegmentLogged = true;
2114
+ logger.info(
2115
+ `cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
2116
+ );
2117
+ }
2118
+ // Formats whose segments need correcting before they are valid against
2119
+ // the session's cached init are read whole and passed through the format
2120
+ // module; the rest stream straight off disk.
2121
+ if (!isPlaylist && this.segmentFormat.needsSegmentRewrite) {
2122
+ const index = this.segmentFormat.segmentIndexFromName(fileName);
2123
+ const bytes = await readFile(filePath);
2124
+ const prepared = this.segmentFormat.prepareSegmentBytes(bytes, {
2125
+ startSeconds: this.#segmentStartTime(session, index),
2126
+ initBytes: session.initBytes ?? null
2127
+ });
2128
+ return {
2129
+ kind: "file",
2130
+ stream: Readable.from([prepared]),
2131
+ contentType: this.segmentFormat.segmentContentType,
2132
+ isPlaylist: false
2133
+ };
2134
+ }
2135
+ return {
2136
+ kind: "file",
2137
+ stream: isPlaylist
2138
+ ? createReadStream(filePath)
2139
+ : createReadStream(filePath, { highWaterMark: SEGMENT_READ_HIGH_WATER_MARK }),
2140
+ contentType: isPlaylist
2141
+ ? "application/vnd.apple.mpegurl"
2142
+ : this.segmentFormat.segmentContentType,
2143
+ isPlaylist
2144
+ };
2145
+ } catch (_error) {
2146
+ // File not produced yet.
2147
+ }
2148
+
2149
+ // A segment was requested that ffmpeg has not produced yet. Decide whether
2150
+ // to wait for the current encode run to reach it or to restart the encoder
2151
+ // at this position (server-side seeking). The caller long-polls.
2152
+ if (!isPlaylist) {
2153
+ this.#ensureEncodingFor(session, this.segmentFormat.segmentIndexFromName(fileName));
2154
+ }
2155
+ return { kind: "warming-up" };
2156
+ }
2157
+
2158
+ /**
2159
+ * Dispose all sessions that have been idle longer than `sessionTtlMs`.
2160
+ * Called automatically on the cleanup interval.
2161
+ *
2162
+ * @returns {Promise<void>}
2163
+ */
2164
+ async cleanupExpired() {
2165
+ const now = Date.now();
2166
+ const idsToDispose = [];
2167
+ for (const [sessionId, session] of this.sessionsById.entries()) {
2168
+ if (now - session.lastAccessedAt > this.sessionTtlMs) {
2169
+ idsToDispose.push(sessionId);
2170
+ }
2171
+ }
2172
+ for (const sessionId of idsToDispose) {
2173
+ await this.disposeSession(sessionId);
2174
+ }
2175
+ }
2176
+
2177
+ /**
2178
+ * Return a progress snapshot for the given session, or `null` if not found.
2179
+ * Also refreshes `lastAccessedAt` to prevent the session from expiring.
2180
+ *
2181
+ * @param {string} sessionId
2182
+ * @returns {Promise<object | null>}
2183
+ */
2184
+ async getSessionProgress(sessionId) {
2185
+ if (!isSafeSessionId(sessionId)) {
2186
+ return null;
2187
+ }
2188
+ const session = this.sessionsById.get(sessionId);
2189
+ if (!session) {
2190
+ return null;
2191
+ }
2192
+ session.lastAccessedAt = Date.now();
2193
+ const warmupTotalSeconds = this.startupWaitMs / 1000;
2194
+ const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
2195
+ const isWarmupPhase = session.state === "starting" || session.progress.state === "starting";
2196
+ const warmupPercent = isWarmupPhase
2197
+ ? Math.max(0, Math.min(100, (warmupElapsedSeconds / warmupTotalSeconds) * 100))
2198
+ : null;
2199
+ const warmupRemainingSeconds = isWarmupPhase
2200
+ ? Math.max(0, warmupTotalSeconds - warmupElapsedSeconds)
2201
+ : null;
2202
+ // Observed OUTPUT bitrate (Mbit/s) from recently completed segment sizes —
2203
+ // already computed for the viewer-link budget check (#checkLinkBudget); also
2204
+ // exposed here so the browser can turn its OWN measured link throughput into
2205
+ // a "content-seconds delivered per wall-clock second" rate for the unified
2206
+ // three-stage ETA (download / transcode / delivery), the same way the
2207
+ // transcode's own `speed` already is one. Null when not enough segments yet.
2208
+ const outputMbps = await this.#observedStreamMbps(session);
2209
+ return {
2210
+ sessionId: session.id,
2211
+ state: session.progress.state,
2212
+ processedSeconds: session.progress.processedSeconds,
2213
+ startPositionSeconds: session.progress.startPositionSeconds ?? 0,
2214
+ totalSeconds: session.progress.totalSeconds,
2215
+ percent: session.progress.percent,
2216
+ remainingSeconds: session.progress.remainingSeconds,
2217
+ warmupPercent,
2218
+ warmupRemainingSeconds,
2219
+ // Segment length, so the browser can show progress toward the FIRST
2220
+ // segment (the only thing it waits for before playback starts) instead
2221
+ // of a percentage of the whole-file transcode.
2222
+ segmentDurationSec: this.segmentDurationSec,
2223
+ speed: session.progress.speed,
2224
+ outputMbps,
2225
+ updatedAt: session.progress.updatedAt,
2226
+ error: session.state === "failed" ? session.lastError : ""
2227
+ };
2228
+ }
2229
+
2230
+ /**
2231
+ * Remove a consumer from a session. Disposes the session when the last
2232
+ * consumer leaves.
2233
+ *
2234
+ * @param {string} sessionId
2235
+ * @param {string} [consumerId=""]
2236
+ * @param {string} [reason=""] - Human-readable reason shown in logs.
2237
+ * @returns {Promise<boolean>} `false` if the session was not found.
2238
+ */
2239
+ async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
2240
+ if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
2241
+ return false;
2242
+ }
2243
+ const session = this.sessionsById.get(sessionId);
2244
+ if (!session) {
2245
+ return false;
2246
+ }
2247
+ if (!(session.consumers instanceof Set)) {
2248
+ session.consumers = new Set();
2249
+ }
2250
+ session.consumers.delete(consumerId);
2251
+ session.lastAccessedAt = Date.now();
2252
+ const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
2253
+ logger.info(
2254
+ `consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
2255
+ `remaining=${session.consumers.size}`
2256
+ );
2257
+ if (session.consumers.size > 0) {
2258
+ return true;
2259
+ }
2260
+ await this.disposeSession(sessionId);
2261
+ return true;
2262
+ }
2263
+
2264
+ /**
2265
+ * Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
2266
+ *
2267
+ * @param {string} sessionId
2268
+ * @returns {Promise<void>}
2269
+ */
2270
+ async disposeSession(sessionId) {
2271
+ const session = this.sessionsById.get(sessionId);
2272
+ if (!session) {
2273
+ return;
2274
+ }
2275
+ session.state = "disposed";
2276
+ this.sessionsById.delete(sessionId);
2277
+ this.sessionIdBySource.delete(session.sourceMapKey);
2278
+
2279
+ // Clear any pending seek-settle timer so it cannot fire and restart a
2280
+ // disposed session.
2281
+ if (session.seekSettleTimer) {
2282
+ clearTimeout(session.seekSettleTimer);
2283
+ session.seekSettleTimer = null;
2284
+ }
2285
+
2286
+ if (session.ffmpeg && !session.ffmpeg.killed) {
2287
+ session.ffmpeg.kill("SIGTERM");
2288
+ await waitForChildExit(session.ffmpeg);
2289
+ }
2290
+ try {
2291
+ await rm(session.dirPath, { recursive: true, force: true });
2292
+ } catch (error) {
2293
+ const message = error instanceof Error ? error.message : String(error);
2294
+ logger.warn(`failed to cleanup HLS temp dir: ${message}`);
2295
+ }
2296
+ }
2297
+
2298
+ /**
2299
+ * Stop the cleanup timer, dispose all active sessions, and attempt to
2300
+ * remove the shared temp root directory if it is empty.
2301
+ * Called by Fastify's `onClose` hook during graceful shutdown.
2302
+ *
2303
+ * @returns {Promise<void>}
2304
+ */
2305
+ async disposeAll() {
2306
+ clearInterval(this.cleanupTimer);
2307
+ clearInterval(this.budgetTimer);
2308
+ const activeIds = Array.from(this.sessionsById.keys());
2309
+ for (const sessionId of activeIds) {
2310
+ await this.disposeSession(sessionId);
2311
+ }
2312
+ const rootDir = path.join(os.tmpdir(), "torrent-tv-hls");
2313
+ try {
2314
+ const dirs = await readdir(rootDir);
2315
+ if (dirs.length === 0) {
2316
+ await rm(rootDir, { recursive: true, force: true });
2317
+ }
2318
+ } catch (_error) {
2319
+ // Best effort cleanup.
2320
+ }
2321
+ }
2322
+ }