@torrent-tv/proxy 2.6.6 → 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.6",
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": {
@@ -18,13 +18,23 @@ import { logger } from "../utils/logger.js";
18
18
 
19
19
  const PLAYLIST_FILE_NAME = "index.m3u8";
20
20
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
21
- const CLEANUP_INTERVAL_MS = 60_000;
21
+ const CLEANUP_INTERVAL_MS = 30_000;
22
22
  const DEFAULT_SEGMENT_DURATION_SEC = 4;
23
23
  // How many segments ahead of the current encode head a missing-segment request
24
24
  // is allowed to be before we restart ffmpeg at that position (server-side seek).
25
25
  // Requests within the window are served by waiting for the running encode.
26
26
  const MAX_LOOKAHEAD_SEGMENTS = 8;
27
- const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
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;
28
38
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
29
39
  const MICROSECONDS_PER_SECOND = 1_000_000;
30
40
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
@@ -488,6 +498,11 @@ export class HlsSessionManager {
488
498
  encodeStartIndex: 0,
489
499
  // Guards against repeatedly restarting to the same seek position.
490
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,
491
506
  progress: {
492
507
  state: "starting",
493
508
  processedSeconds: 0,
@@ -575,14 +590,19 @@ export class HlsSessionManager {
575
590
 
576
591
  // Terminate any existing encode process before starting a new one. The
577
592
  // old process's exit handler no-ops because session.ffmpeg is reassigned
578
- // below (it checks identity).
593
+ // below (it checks identity). Resume first in case it was paused, so the
594
+ // termination signal is actually delivered.
579
595
  if (session.ffmpeg && !session.ffmpeg.killed) {
580
596
  try {
597
+ if (session.paused) {
598
+ session.ffmpeg.kill("SIGCONT");
599
+ }
581
600
  session.ffmpeg.kill("SIGTERM");
582
601
  } catch (_error) {
583
602
  // Best effort.
584
603
  }
585
604
  }
605
+ session.paused = false;
586
606
 
587
607
  const videoCodecArgs = session.transcodeVideo
588
608
  ? [
@@ -649,6 +669,7 @@ export class HlsSessionManager {
649
669
  session.ffmpeg = ffmpeg;
650
670
  session.encodeStartIndex = safeIndex;
651
671
  session.pendingRestartIndex = -1;
672
+ session.playheadIndex = safeIndex;
652
673
  session.state = session.state === "disposed" ? "disposed" : "starting";
653
674
  session.progress.state = "running";
654
675
  session.progress.processedSeconds = startSeconds;
@@ -721,6 +742,8 @@ export class HlsSessionManager {
721
742
  ` speed=${session.progress.speed || "n/a"}`
722
743
  );
723
744
  }
745
+ // Just-in-time pacing: pause once we are far enough ahead of the player.
746
+ this.#maybePauseEncoder(session);
724
747
  }
725
748
  });
726
749
 
@@ -796,6 +819,79 @@ export class HlsSessionManager {
796
819
  this.#startEncodeRun(session, index);
797
820
  }
798
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
+ }
856
+ try {
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);
892
+ }
893
+ }
894
+
799
895
  #buildVideoFilter(targetWidth, targetHeight) {
800
896
  const safeWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
801
897
  const safeHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
@@ -874,6 +970,16 @@ export class HlsSessionManager {
874
970
  }
875
971
  session.lastAccessedAt = Date.now();
876
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
+
877
983
  // Serve the synthetic VOD playlist (full duration, terminated with
878
984
  // #EXT-X-ENDLIST) so the player gets the correct total length and a fully
879
985
  // seekable timeline up-front, independent of how far ffmpeg has encoded.
@@ -1021,6 +1127,16 @@ export class HlsSessionManager {
1021
1127
  this.sessionIdBySource.delete(session.sourceMapKey);
1022
1128
 
1023
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
+ }
1024
1140
  session.ffmpeg.kill("SIGTERM");
1025
1141
  await waitForChildExit(session.ffmpeg);
1026
1142
  }