@torrent-tv/proxy 2.6.4 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
|
|
15
15
|
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
16
16
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
17
|
-
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName,
|
|
17
|
+
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, 30_000);
|
|
18
18
|
|
|
19
19
|
if (result.kind === "not-found") {
|
|
20
20
|
return reply.code(404).send({ error: "Transcode session file was not found." });
|
|
21
21
|
}
|
|
22
22
|
if (result.kind === "warming-up") {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// The segment is still being produced (e.g. just after a seek-restart).
|
|
24
|
+
// Return a retryable 503 — never 202, which hls.js cannot consume as a
|
|
25
|
+
// media segment — so the player retries the fetch shortly.
|
|
26
|
+
reply.header("Retry-After", "1");
|
|
27
|
+
return reply.code(503).send({ error: "Transcode segment is still being produced." });
|
|
25
28
|
}
|
|
26
29
|
if (result.kind === "failed") {
|
|
27
30
|
return reply.code(500).send({ error: result.message });
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { createReadStream } from "node:fs";
|
|
11
11
|
import { access, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
12
|
+
import { Readable } from "node:stream";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { randomUUID } from "node:crypto";
|
|
@@ -17,9 +18,23 @@ import { logger } from "../utils/logger.js";
|
|
|
17
18
|
|
|
18
19
|
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
19
20
|
const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
|
|
20
|
-
const CLEANUP_INTERVAL_MS =
|
|
21
|
+
const CLEANUP_INTERVAL_MS = 30_000;
|
|
21
22
|
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
22
|
-
|
|
23
|
+
// How many segments ahead of the current encode head a missing-segment request
|
|
24
|
+
// is allowed to be before we restart ffmpeg at that position (server-side seek).
|
|
25
|
+
// Requests within the window are served by waiting for the running encode.
|
|
26
|
+
const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
27
|
+
// Just-in-time pacing: pause the encoder once it is this many segments ahead of
|
|
28
|
+
// the player's current position, and resume once the lead shrinks to the resume
|
|
29
|
+
// threshold. This keeps a comfortable buffer for smooth playback while capping
|
|
30
|
+
// CPU near real-time instead of racing through the whole file at once.
|
|
31
|
+
const PACING_PAUSE_AHEAD_SEGMENTS = 8;
|
|
32
|
+
const PACING_RESUME_AHEAD_SEGMENTS = 4;
|
|
33
|
+
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
34
|
+
// access. Kept short so an ffmpeg process does not keep burning CPU after the
|
|
35
|
+
// viewer stops or navigates away. Active playback refreshes the timer on every
|
|
36
|
+
// segment fetch, so it never expires mid-watch.
|
|
37
|
+
const DEFAULT_SESSION_TTL_MS = 120 * 1000;
|
|
23
38
|
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
24
39
|
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
25
40
|
const PROGRESS_LOG_INTERVAL_MS = 5_000;
|
|
@@ -120,6 +135,21 @@ function isSafeFileName(fileName) {
|
|
|
120
135
|
return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Extract the zero-based segment index from a segment file name.
|
|
140
|
+
* Returns -1 when the name is not a valid segment file.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} fileName - e.g. "segment-00012.ts"
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
function segmentIndexFromName(fileName) {
|
|
146
|
+
const match = /^segment-(\d{5})\.ts$/.exec(fileName);
|
|
147
|
+
if (!match) {
|
|
148
|
+
return -1;
|
|
149
|
+
}
|
|
150
|
+
return Number(match[1]);
|
|
151
|
+
}
|
|
152
|
+
|
|
123
153
|
/**
|
|
124
154
|
* Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
|
|
125
155
|
* Returns `null` if the value is absent or malformed.
|
|
@@ -421,12 +451,163 @@ export class HlsSessionManager {
|
|
|
421
451
|
const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
422
452
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
423
453
|
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
454
|
+
|
|
455
|
+
// Probe the full media duration up-front so we can serve a complete VOD
|
|
456
|
+
// playlist (terminated with #EXT-X-ENDLIST) immediately. This gives the
|
|
457
|
+
// player the correct total duration and a fully seekable timeline before a
|
|
458
|
+
// single segment has been transcoded.
|
|
424
459
|
const durationSeconds = await probeInputDurationSeconds(this.ffmpegBin, inputUrl.toString());
|
|
460
|
+
const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
|
|
461
|
+
const logName = normalizeLogFileName(fileName, fileIndex);
|
|
462
|
+
if (!hasDuration) {
|
|
463
|
+
logger.warn(
|
|
464
|
+
`transcode ${sessionId}: could not probe duration; falling back to ` +
|
|
465
|
+
`ffmpeg-managed (growing) playlist for "${logName}"`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const segmentCount = hasDuration
|
|
469
|
+
? Math.max(1, Math.ceil(durationSeconds / this.segmentDurationSec))
|
|
470
|
+
: 0;
|
|
471
|
+
|
|
472
|
+
const session = {
|
|
473
|
+
id: sessionId,
|
|
474
|
+
sourceMapKey,
|
|
475
|
+
fileName: logName,
|
|
476
|
+
dirPath: sessionDir,
|
|
477
|
+
state: "starting",
|
|
478
|
+
startedAt: Date.now(),
|
|
479
|
+
lastAccessedAt: Date.now(),
|
|
480
|
+
ffmpeg: null,
|
|
481
|
+
lastError: "",
|
|
482
|
+
consumers: new Set(consumerId ? [consumerId] : []),
|
|
483
|
+
// Transcode parameters retained so the encode run can be restarted at an
|
|
484
|
+
// arbitrary segment when the player seeks (server-side seeking).
|
|
485
|
+
sourceKey,
|
|
486
|
+
fileIndex,
|
|
487
|
+
transcodeVideo,
|
|
488
|
+
transcodeAudio,
|
|
489
|
+
targetWidth: normalizedTargetWidth,
|
|
490
|
+
targetHeight: normalizedTargetHeight,
|
|
491
|
+
inputUrl: inputUrl.toString(),
|
|
492
|
+
// VOD playlist bookkeeping.
|
|
493
|
+
useSyntheticPlaylist: hasDuration,
|
|
494
|
+
totalDurationSeconds: hasDuration ? durationSeconds : null,
|
|
495
|
+
segmentCount,
|
|
496
|
+
playlistText: hasDuration ? this.#buildVodPlaylist(durationSeconds, this.segmentDurationSec) : "",
|
|
497
|
+
// Segment index the current ffmpeg run started producing from.
|
|
498
|
+
encodeStartIndex: 0,
|
|
499
|
+
// Guards against repeatedly restarting to the same seek position.
|
|
500
|
+
pendingRestartIndex: -1,
|
|
501
|
+
// Just-in-time pacing: highest segment index the player has requested
|
|
502
|
+
// (≈ playhead) and whether the encoder is currently paused for running
|
|
503
|
+
// far enough ahead.
|
|
504
|
+
playheadIndex: 0,
|
|
505
|
+
paused: false,
|
|
506
|
+
progress: {
|
|
507
|
+
state: "starting",
|
|
508
|
+
processedSeconds: 0,
|
|
509
|
+
startPositionSeconds: 0,
|
|
510
|
+
totalSeconds: hasDuration ? durationSeconds : null,
|
|
511
|
+
percent: null,
|
|
512
|
+
remainingSeconds: hasDuration ? durationSeconds : null,
|
|
513
|
+
speed: "",
|
|
514
|
+
updatedAt: Date.now(),
|
|
515
|
+
lastLoggedAt: 0
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
this.sessionsById.set(sessionId, session);
|
|
519
|
+
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
520
|
+
|
|
521
|
+
logger.info(
|
|
522
|
+
`transcode ${sessionId} start "${logName}" ` +
|
|
523
|
+
`video=${transcodeVideo ? "x264" : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
|
|
524
|
+
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
this.#startEncodeRun(session, 0);
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
await this.waitUntilReady(session);
|
|
531
|
+
return session;
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (session.state === "failed") {
|
|
534
|
+
await this.disposeSession(session.id);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
// Do not fail session creation on warmup timeout; the synthetic playlist
|
|
538
|
+
// is already available and segments appear as ffmpeg produces them.
|
|
539
|
+
return session;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Build a complete VOD HLS playlist for the full media duration.
|
|
545
|
+
*
|
|
546
|
+
* The playlist lists every segment up-front and is terminated with
|
|
547
|
+
* `#EXT-X-ENDLIST`, so the player knows the total duration and can seek to
|
|
548
|
+
* any position immediately — even before the corresponding segment has been
|
|
549
|
+
* transcoded. Segments are produced on demand (see {@link getFileStream}).
|
|
550
|
+
*
|
|
551
|
+
* @param {number} totalSeconds
|
|
552
|
+
* @param {number} segSec
|
|
553
|
+
* @returns {string}
|
|
554
|
+
*/
|
|
555
|
+
#buildVodPlaylist(totalSeconds, segSec) {
|
|
556
|
+
const count = Math.max(1, Math.ceil(totalSeconds / segSec));
|
|
557
|
+
const lines = [
|
|
558
|
+
"#EXTM3U",
|
|
559
|
+
"#EXT-X-VERSION:3",
|
|
560
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(segSec)}`,
|
|
561
|
+
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
562
|
+
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
563
|
+
"#EXT-X-INDEPENDENT-SEGMENTS"
|
|
564
|
+
];
|
|
565
|
+
for (let index = 0; index < count; index += 1) {
|
|
566
|
+
const remaining = totalSeconds - index * segSec;
|
|
567
|
+
const duration = index < count - 1 ? segSec : Math.max(0.1, remaining);
|
|
568
|
+
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
569
|
+
lines.push(`segment-${String(index).padStart(5, "0")}.ts`);
|
|
570
|
+
}
|
|
571
|
+
lines.push("#EXT-X-ENDLIST");
|
|
572
|
+
return `${lines.join("\n")}\n`;
|
|
573
|
+
}
|
|
425
574
|
|
|
426
|
-
|
|
575
|
+
/**
|
|
576
|
+
* (Re)start the ffmpeg encode run beginning at segment `startIndex`.
|
|
577
|
+
*
|
|
578
|
+
* Any ffmpeg process currently running for this session is terminated first.
|
|
579
|
+
* Segment files are named with a global index (`-start_number`) so they
|
|
580
|
+
* always line up with the synthetic VOD playlist regardless of where
|
|
581
|
+
* encoding started — this is what makes server-side seeking work.
|
|
582
|
+
*
|
|
583
|
+
* @param {HlsSession} session
|
|
584
|
+
* @param {number} startIndex
|
|
585
|
+
* @returns {void}
|
|
586
|
+
*/
|
|
587
|
+
#startEncodeRun(session, startIndex) {
|
|
588
|
+
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
589
|
+
const startSeconds = safeIndex * this.segmentDurationSec;
|
|
590
|
+
|
|
591
|
+
// Terminate any existing encode process before starting a new one. The
|
|
592
|
+
// old process's exit handler no-ops because session.ffmpeg is reassigned
|
|
593
|
+
// below (it checks identity). Resume first in case it was paused, so the
|
|
594
|
+
// termination signal is actually delivered.
|
|
595
|
+
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
596
|
+
try {
|
|
597
|
+
if (session.paused) {
|
|
598
|
+
session.ffmpeg.kill("SIGCONT");
|
|
599
|
+
}
|
|
600
|
+
session.ffmpeg.kill("SIGTERM");
|
|
601
|
+
} catch (_error) {
|
|
602
|
+
// Best effort.
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
session.paused = false;
|
|
606
|
+
|
|
607
|
+
const videoCodecArgs = session.transcodeVideo
|
|
427
608
|
? [
|
|
428
609
|
"-vf",
|
|
429
|
-
this.#buildVideoFilter(
|
|
610
|
+
this.#buildVideoFilter(session.targetWidth, session.targetHeight),
|
|
430
611
|
"-c:v",
|
|
431
612
|
"libx264",
|
|
432
613
|
"-preset",
|
|
@@ -434,29 +615,29 @@ export class HlsSessionManager {
|
|
|
434
615
|
"-crf",
|
|
435
616
|
VIDEO_TRANSCODE_CRF,
|
|
436
617
|
"-pix_fmt",
|
|
437
|
-
"yuv420p"
|
|
618
|
+
"yuv420p",
|
|
619
|
+
// Force keyframes on segment boundaries so each segment is
|
|
620
|
+
// independently decodable and exactly segmentDuration long — this
|
|
621
|
+
// keeps the synthetic playlist's timing accurate.
|
|
622
|
+
"-force_key_frames",
|
|
623
|
+
`expr:gte(t,n_forced*${this.segmentDurationSec})`
|
|
438
624
|
]
|
|
439
625
|
: ["-c:v", "copy"];
|
|
440
|
-
const audioCodecArgs = transcodeAudio
|
|
626
|
+
const audioCodecArgs = session.transcodeAudio
|
|
441
627
|
? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
|
|
442
628
|
: ["-c:a", "copy"];
|
|
443
629
|
|
|
444
630
|
const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (normalizedStartPosition > 0) {
|
|
449
|
-
args.push("-ss", String(normalizedStartPosition));
|
|
631
|
+
if (startSeconds > 0) {
|
|
632
|
+
// Fast keyframe-level seek before -i (skips decoding earlier frames).
|
|
633
|
+
args.push("-ss", String(startSeconds));
|
|
450
634
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (normalizedStartPosition > 0) {
|
|
457
|
-
args.push("-output_ts_offset", String(normalizedStartPosition));
|
|
635
|
+
args.push("-i", session.inputUrl);
|
|
636
|
+
if (startSeconds > 0) {
|
|
637
|
+
// Keep output timestamps on the original timeline so video.currentTime
|
|
638
|
+
// matches the requested position.
|
|
639
|
+
args.push("-output_ts_offset", String(startSeconds));
|
|
458
640
|
}
|
|
459
|
-
|
|
460
641
|
args.push(
|
|
461
642
|
"-map",
|
|
462
643
|
"0:v:0?",
|
|
@@ -470,48 +651,49 @@ export class HlsSessionManager {
|
|
|
470
651
|
String(this.segmentDurationSec),
|
|
471
652
|
"-hls_list_size",
|
|
472
653
|
"0",
|
|
473
|
-
"-hls_playlist_type",
|
|
474
|
-
"event",
|
|
475
654
|
"-hls_flags",
|
|
476
655
|
"independent_segments+temp_file",
|
|
656
|
+
"-start_number",
|
|
657
|
+
String(safeIndex),
|
|
477
658
|
"-hls_segment_filename",
|
|
478
659
|
"segment-%05d.ts",
|
|
660
|
+
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
661
|
+
// synthetic VOD playlist instead (see getFileStream).
|
|
479
662
|
PLAYLIST_FILE_NAME
|
|
480
663
|
);
|
|
481
664
|
|
|
482
665
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
483
|
-
cwd:
|
|
666
|
+
cwd: session.dirPath,
|
|
484
667
|
stdio: ["ignore", "pipe", "pipe"]
|
|
485
668
|
});
|
|
669
|
+
session.ffmpeg = ffmpeg;
|
|
670
|
+
session.encodeStartIndex = safeIndex;
|
|
671
|
+
session.pendingRestartIndex = -1;
|
|
672
|
+
session.playheadIndex = safeIndex;
|
|
673
|
+
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
674
|
+
session.progress.state = "running";
|
|
675
|
+
session.progress.processedSeconds = startSeconds;
|
|
676
|
+
session.progress.startPositionSeconds = startSeconds;
|
|
677
|
+
session.progress.updatedAt = Date.now();
|
|
486
678
|
|
|
487
|
-
|
|
488
|
-
id
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
lastAccessedAt: Date.now(),
|
|
495
|
-
ffmpeg,
|
|
496
|
-
lastError: "",
|
|
497
|
-
consumers: new Set(consumerId ? [consumerId] : []),
|
|
498
|
-
progress: {
|
|
499
|
-
state: "starting",
|
|
500
|
-
// With -output_ts_offset the first out_time will be ≈ normalizedStartPosition,
|
|
501
|
-
// so initialise processedSeconds to that value for consistent percent math.
|
|
502
|
-
processedSeconds: normalizedStartPosition,
|
|
503
|
-
startPositionSeconds: normalizedStartPosition,
|
|
504
|
-
totalSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null,
|
|
505
|
-
percent: null,
|
|
506
|
-
remainingSeconds: Number.isFinite(durationSeconds) ? durationSeconds - normalizedStartPosition : null,
|
|
507
|
-
speed: "",
|
|
508
|
-
updatedAt: Date.now(),
|
|
509
|
-
lastLoggedAt: 0
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
this.sessionsById.set(sessionId, session);
|
|
513
|
-
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
679
|
+
logger.info(
|
|
680
|
+
`transcode ${session.id} encode-run from segment #${safeIndex} ` +
|
|
681
|
+
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
this.#wireEncodeProcess(session, ffmpeg);
|
|
685
|
+
}
|
|
514
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Wire stdout (progress), stderr (errors) and exit handlers for an ffmpeg
|
|
689
|
+
* encode process. Handlers no-op when the process has been superseded by a
|
|
690
|
+
* later encode run (identity check against `session.ffmpeg`).
|
|
691
|
+
*
|
|
692
|
+
* @param {HlsSession} session
|
|
693
|
+
* @param {import("node:child_process").ChildProcess} ffmpeg
|
|
694
|
+
* @returns {void}
|
|
695
|
+
*/
|
|
696
|
+
#wireEncodeProcess(session, ffmpeg) {
|
|
515
697
|
ffmpeg.stdout.on("data", (chunk) => {
|
|
516
698
|
const lines = String(chunk).split(/\r?\n/);
|
|
517
699
|
for (const line of lines) {
|
|
@@ -560,6 +742,8 @@ export class HlsSessionManager {
|
|
|
560
742
|
` speed=${session.progress.speed || "n/a"}`
|
|
561
743
|
);
|
|
562
744
|
}
|
|
745
|
+
// Just-in-time pacing: pause once we are far enough ahead of the player.
|
|
746
|
+
this.#maybePauseEncoder(session);
|
|
563
747
|
}
|
|
564
748
|
});
|
|
565
749
|
|
|
@@ -567,19 +751,26 @@ export class HlsSessionManager {
|
|
|
567
751
|
const line = String(chunk).trim();
|
|
568
752
|
if (line.length > 0) {
|
|
569
753
|
session.lastError = line;
|
|
570
|
-
logger.warn(`ffmpeg: ${line}`);
|
|
754
|
+
logger.warn(`ffmpeg ${session.id}: ${line}`);
|
|
571
755
|
}
|
|
572
756
|
});
|
|
573
757
|
|
|
574
758
|
ffmpeg.on("error", (error) => {
|
|
759
|
+
if (session.ffmpeg !== ffmpeg) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
575
762
|
session.state = "failed";
|
|
576
763
|
session.lastError = error instanceof Error ? error.message : String(error);
|
|
577
764
|
session.progress.state = "failed";
|
|
578
765
|
session.progress.updatedAt = Date.now();
|
|
579
|
-
logger.error(`ffmpeg process error: ${session.lastError}`);
|
|
766
|
+
logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
|
|
580
767
|
});
|
|
581
768
|
|
|
582
|
-
ffmpeg.on("exit", (code) => {
|
|
769
|
+
ffmpeg.on("exit", (code, signal) => {
|
|
770
|
+
// Ignore the exit of a process that was superseded by a seek-restart.
|
|
771
|
+
if (session.ffmpeg !== ffmpeg) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
583
774
|
if (session.state === "disposed") {
|
|
584
775
|
return;
|
|
585
776
|
}
|
|
@@ -587,26 +778,117 @@ export class HlsSessionManager {
|
|
|
587
778
|
session.state = "ready";
|
|
588
779
|
session.progress.state = "ready";
|
|
589
780
|
session.progress.updatedAt = Date.now();
|
|
781
|
+
logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
|
|
590
782
|
return;
|
|
591
783
|
}
|
|
592
784
|
session.state = "failed";
|
|
593
785
|
session.progress.state = "failed";
|
|
594
786
|
session.progress.updatedAt = Date.now();
|
|
595
787
|
if (!session.lastError) {
|
|
596
|
-
session.lastError = `ffmpeg exited with code ${code ?? -1}`;
|
|
788
|
+
session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
|
|
597
789
|
}
|
|
790
|
+
logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
|
|
598
791
|
});
|
|
792
|
+
}
|
|
599
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Ensure the encoder is producing (or will soon produce) the requested
|
|
796
|
+
* segment. If the segment is far ahead of the current encode head, or
|
|
797
|
+
* behind it, restart ffmpeg at that segment (server-side seek). Requests
|
|
798
|
+
* within the look-ahead window are served by waiting for the running encode.
|
|
799
|
+
*
|
|
800
|
+
* @param {HlsSession} session
|
|
801
|
+
* @param {number} index
|
|
802
|
+
* @returns {void}
|
|
803
|
+
*/
|
|
804
|
+
#ensureEncodingFor(session, index) {
|
|
805
|
+
if (!session || session.state === "disposed" || index < 0) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const head = session.encodeStartIndex;
|
|
809
|
+
const withinWindow = index >= head && index <= head + MAX_LOOKAHEAD_SEGMENTS;
|
|
810
|
+
if (withinWindow) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (session.pendingRestartIndex === index) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
logger.info(
|
|
817
|
+
`transcode ${session.id} seek → restart at segment #${index} (encode head #${head})`
|
|
818
|
+
);
|
|
819
|
+
this.#startEncodeRun(session, index);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Segment index ffmpeg has encoded up to so far, derived from the output
|
|
824
|
+
* timestamp reported via -progress.
|
|
825
|
+
*
|
|
826
|
+
* @param {HlsSession} session
|
|
827
|
+
* @returns {number}
|
|
828
|
+
*/
|
|
829
|
+
#producedSegmentIndex(session) {
|
|
830
|
+
const processed = Number(session.progress?.processedSeconds);
|
|
831
|
+
if (!Number.isFinite(processed) || processed <= 0) {
|
|
832
|
+
return session.encodeStartIndex;
|
|
833
|
+
}
|
|
834
|
+
return Math.floor(processed / this.segmentDurationSec);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Pause the encoder once it has produced enough segments ahead of the
|
|
839
|
+
* player's current position (just-in-time pacing). No-op on platforms
|
|
840
|
+
* without POSIX job-control signals (fail-open: the encoder keeps running).
|
|
841
|
+
*
|
|
842
|
+
* @param {HlsSession} session
|
|
843
|
+
* @returns {void}
|
|
844
|
+
*/
|
|
845
|
+
#maybePauseEncoder(session) {
|
|
846
|
+
if (session.paused || !session.ffmpeg || session.ffmpeg.killed) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (process.platform === "win32") {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const ahead = this.#producedSegmentIndex(session) - session.playheadIndex;
|
|
853
|
+
if (ahead < PACING_PAUSE_AHEAD_SEGMENTS) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
600
856
|
try {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
857
|
+
session.ffmpeg.kill("SIGSTOP");
|
|
858
|
+
session.paused = true;
|
|
859
|
+
logger.info(
|
|
860
|
+
`transcode ${session.id} paused (buffered ${ahead} segments ahead) "${session.fileName}"`
|
|
861
|
+
);
|
|
862
|
+
} catch (_error) {
|
|
863
|
+
// Fail-open: if we cannot pause, let the encoder keep running.
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Resume a paused encoder when the player has caught up enough that the
|
|
869
|
+
* buffered lead has shrunk to the resume threshold.
|
|
870
|
+
*
|
|
871
|
+
* @param {HlsSession} session
|
|
872
|
+
* @returns {void}
|
|
873
|
+
*/
|
|
874
|
+
#maybeResumeEncoder(session) {
|
|
875
|
+
if (!session.paused || !session.ffmpeg || session.ffmpeg.killed) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const ahead = this.#producedSegmentIndex(session) - session.playheadIndex;
|
|
879
|
+
if (ahead > PACING_RESUME_AHEAD_SEGMENTS) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
try {
|
|
883
|
+
session.ffmpeg.kill("SIGCONT");
|
|
884
|
+
session.paused = false;
|
|
885
|
+
session.progress.updatedAt = Date.now();
|
|
886
|
+
logger.info(`transcode ${session.id} resumed (lead ${ahead} segments) "${session.fileName}"`);
|
|
887
|
+
} catch (_error) {
|
|
888
|
+
// If resume fails, force a restart from the player's position so it does
|
|
889
|
+
// not stall (fail-safe toward smooth playback).
|
|
890
|
+
session.paused = false;
|
|
891
|
+
this.#startEncodeRun(session, session.playheadIndex);
|
|
610
892
|
}
|
|
611
893
|
}
|
|
612
894
|
|
|
@@ -625,6 +907,18 @@ export class HlsSessionManager {
|
|
|
625
907
|
* @returns {Promise<void>}
|
|
626
908
|
*/
|
|
627
909
|
async waitUntilReady(session) {
|
|
910
|
+
// With a synthetic VOD playlist there is nothing to wait for: the playlist
|
|
911
|
+
// is generated from the probed duration and is available immediately.
|
|
912
|
+
// Individual segments are long-polled by the segment route as ffmpeg
|
|
913
|
+
// produces them.
|
|
914
|
+
if (session.useSyntheticPlaylist) {
|
|
915
|
+
if (session.state === "failed") {
|
|
916
|
+
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
917
|
+
}
|
|
918
|
+
session.state = "ready";
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
628
922
|
const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
|
|
629
923
|
const deadline = Date.now() + this.startupWaitMs;
|
|
630
924
|
|
|
@@ -675,21 +969,52 @@ export class HlsSessionManager {
|
|
|
675
969
|
};
|
|
676
970
|
}
|
|
677
971
|
session.lastAccessedAt = Date.now();
|
|
972
|
+
|
|
973
|
+
// Track the player position (≈ playhead) from segment requests and resume
|
|
974
|
+
// a paused encoder as the player approaches the buffered edge.
|
|
975
|
+
if (fileName !== PLAYLIST_FILE_NAME) {
|
|
976
|
+
const requestedIndex = segmentIndexFromName(fileName);
|
|
977
|
+
if (requestedIndex >= 0) {
|
|
978
|
+
session.playheadIndex = Math.max(session.playheadIndex, requestedIndex);
|
|
979
|
+
this.#maybeResumeEncoder(session);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Serve the synthetic VOD playlist (full duration, terminated with
|
|
984
|
+
// #EXT-X-ENDLIST) so the player gets the correct total length and a fully
|
|
985
|
+
// seekable timeline up-front, independent of how far ffmpeg has encoded.
|
|
986
|
+
if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
|
|
987
|
+
return {
|
|
988
|
+
kind: "file",
|
|
989
|
+
stream: Readable.from([session.playlistText]),
|
|
990
|
+
contentType: "application/vnd.apple.mpegurl",
|
|
991
|
+
isPlaylist: true
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
678
995
|
const filePath = path.join(session.dirPath, fileName);
|
|
679
996
|
try {
|
|
680
997
|
await access(filePath);
|
|
998
|
+
return {
|
|
999
|
+
kind: "file",
|
|
1000
|
+
stream: createReadStream(filePath),
|
|
1001
|
+
contentType:
|
|
1002
|
+
fileName === PLAYLIST_FILE_NAME
|
|
1003
|
+
? "application/vnd.apple.mpegurl"
|
|
1004
|
+
: "video/mp2t",
|
|
1005
|
+
isPlaylist: fileName === PLAYLIST_FILE_NAME
|
|
1006
|
+
};
|
|
681
1007
|
} catch (_error) {
|
|
682
|
-
|
|
1008
|
+
// File not produced yet.
|
|
683
1009
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
};
|
|
1010
|
+
|
|
1011
|
+
// A segment was requested that ffmpeg has not produced yet. Decide whether
|
|
1012
|
+
// to wait for the current encode run to reach it or to restart the encoder
|
|
1013
|
+
// at this position (server-side seeking). The caller long-polls.
|
|
1014
|
+
if (fileName !== PLAYLIST_FILE_NAME) {
|
|
1015
|
+
this.#ensureEncodingFor(session, segmentIndexFromName(fileName));
|
|
1016
|
+
}
|
|
1017
|
+
return { kind: "warming-up" };
|
|
693
1018
|
}
|
|
694
1019
|
|
|
695
1020
|
/**
|
|
@@ -802,6 +1127,16 @@ export class HlsSessionManager {
|
|
|
802
1127
|
this.sessionIdBySource.delete(session.sourceMapKey);
|
|
803
1128
|
|
|
804
1129
|
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
1130
|
+
// Resume first if paused, otherwise SIGTERM is not delivered to a
|
|
1131
|
+
// stopped process.
|
|
1132
|
+
if (session.paused) {
|
|
1133
|
+
try {
|
|
1134
|
+
session.ffmpeg.kill("SIGCONT");
|
|
1135
|
+
} catch (_error) {
|
|
1136
|
+
// Best effort.
|
|
1137
|
+
}
|
|
1138
|
+
session.paused = false;
|
|
1139
|
+
}
|
|
805
1140
|
session.ffmpeg.kill("SIGTERM");
|
|
806
1141
|
await waitForChildExit(session.ffmpeg);
|
|
807
1142
|
}
|
|
@@ -20,9 +20,19 @@ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
|
20
20
|
function parseStreamCodecs(ffmpegOutput) {
|
|
21
21
|
const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
|
|
22
22
|
const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
|
|
23
|
+
const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
|
|
24
|
+
const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
|
|
25
|
+
let durationSeconds = 0;
|
|
26
|
+
if (durationMatch) {
|
|
27
|
+
const value =
|
|
28
|
+
Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
|
|
29
|
+
durationSeconds = Number.isFinite(value) ? value : 0;
|
|
30
|
+
}
|
|
23
31
|
return {
|
|
24
32
|
audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
|
|
25
|
-
videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : ""
|
|
33
|
+
videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
|
|
34
|
+
container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
|
|
35
|
+
durationSeconds
|
|
26
36
|
};
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -43,7 +53,11 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
|
|
|
43
53
|
if (typeof userAgent === "string" && userAgent.trim().length > 0) {
|
|
44
54
|
args.push("-user_agent", userAgent.trim());
|
|
45
55
|
}
|
|
46
|
-
|
|
56
|
+
// Decode a tiny slice of all streams (no per-stream -map, so video-only
|
|
57
|
+
// files probe correctly too). The ffmpeg banner that precedes decoding
|
|
58
|
+
// gives us audio/video codecs, the container format and the duration in a
|
|
59
|
+
// single pass.
|
|
60
|
+
args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
|
|
47
61
|
|
|
48
62
|
const ffmpeg = spawn(ffmpegBin, args, {
|
|
49
63
|
stdio: ["ignore", "ignore", "pipe"],
|
|
@@ -105,6 +119,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
|
105
119
|
* @property {string} reason - Human-readable explanation of the chosen mode.
|
|
106
120
|
* @property {string} audioCodec
|
|
107
121
|
* @property {string} videoCodec
|
|
122
|
+
* @property {string} container - Demuxer/container name(s) reported by ffmpeg.
|
|
123
|
+
* @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
|
|
108
124
|
*/
|
|
109
125
|
|
|
110
126
|
/**
|
|
@@ -174,7 +190,9 @@ export function createPlaybackPlanner({
|
|
|
174
190
|
directUrl,
|
|
175
191
|
reason: "transcode-disabled",
|
|
176
192
|
audioCodec: "",
|
|
177
|
-
videoCodec: ""
|
|
193
|
+
videoCodec: "",
|
|
194
|
+
container: "",
|
|
195
|
+
durationSeconds: 0
|
|
178
196
|
};
|
|
179
197
|
cache.set(cacheKey, plan);
|
|
180
198
|
return plan;
|
|
@@ -186,7 +204,7 @@ export function createPlaybackPlanner({
|
|
|
186
204
|
// the end of the file and hasn't been downloaded yet.
|
|
187
205
|
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
188
206
|
|
|
189
|
-
const { audioCodec, videoCodec } = await probeStreamCodecs({
|
|
207
|
+
const { audioCodec, videoCodec, container, durationSeconds } = await probeStreamCodecs({
|
|
190
208
|
ffmpegBin,
|
|
191
209
|
inputUrl: directUrl,
|
|
192
210
|
userAgent
|
|
@@ -199,11 +217,16 @@ export function createPlaybackPlanner({
|
|
|
199
217
|
// browser-side loading pipeline already has its own transcode fallback.
|
|
200
218
|
const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
201
219
|
const plan = {
|
|
220
|
+
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
221
|
+
// authoritative decision independently per stream via canPlayType /
|
|
222
|
+
// mediaCapabilities, transcoding only what it cannot play.
|
|
202
223
|
mode: requiresTranscode ? "hls" : "direct",
|
|
203
224
|
directUrl,
|
|
204
225
|
reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
|
|
205
226
|
audioCodec,
|
|
206
|
-
videoCodec
|
|
227
|
+
videoCodec,
|
|
228
|
+
container,
|
|
229
|
+
durationSeconds
|
|
207
230
|
};
|
|
208
231
|
cache.set(cacheKey, plan);
|
|
209
232
|
return plan;
|