@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.6.4",
3
+ "version": "2.7.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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, 15_000);
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
- reply.header("Retry-After", "2");
24
- return reply.code(202).send({ status: "warming-up" });
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 = 60_000;
21
+ const CLEANUP_INTERVAL_MS = 30_000;
21
22
  const DEFAULT_SEGMENT_DURATION_SEC = 4;
22
- const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
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
- const videoCodecArgs = transcodeVideo
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(normalizedTargetWidth, normalizedTargetHeight),
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
- // Fast keyframe-level seek before -i. This is efficient because ffmpeg
447
- // skips decoding all frames before the target position.
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
- args.push("-i", inputUrl.toString());
453
-
454
- // Shift output timestamps to match the original timeline so that
455
- // video.currentTime reflects the seeked position, not a reset-to-zero.
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: sessionDir,
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
- const session = {
488
- id: sessionId,
489
- sourceMapKey,
490
- fileName: normalizeLogFileName(fileName, fileIndex),
491
- dirPath: sessionDir,
492
- state: "starting",
493
- startedAt: Date.now(),
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
- await this.waitUntilReady(session);
602
- return session;
603
- } catch (error) {
604
- if (session.state === "failed") {
605
- await this.disposeSession(session.id);
606
- throw error;
607
- }
608
- // Do not fail session creation on warmup timeout; playlist can appear later.
609
- return session;
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
- return { kind: "warming-up" };
1008
+ // File not produced yet.
683
1009
  }
684
- return {
685
- kind: "file",
686
- stream: createReadStream(filePath),
687
- contentType:
688
- fileName === PLAYLIST_FILE_NAME
689
- ? "application/vnd.apple.mpegurl"
690
- : "video/mp2t",
691
- isPlaylist: fileName === PLAYLIST_FILE_NAME
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
- args.push("-i", inputUrl, "-map", "0:a:0", "-t", "0.1", "-f", "null", "-");
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;