@torrent-tv/proxy 2.95.33 → 2.95.35

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.95.33",
3
+ "version": "2.95.35",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -21,10 +21,14 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
21
21
  if (!range || range.start >= length) return reply.code(416).header("Content-Range", `bytes */${length}`).send();
22
22
  // The answer runs to the end of the admitted range the read starts in, which
23
23
  // is what keeps FFmpeg inside the bytes the run holds. A shorter answer is
24
- // not continued by every FFmpeg: 6.1 (ffmpeg-static) takes it for the end of
25
- // the input and stops, where 8.1 sends a new request (http.c, EAGAIN at the
26
- // end of a content range). A cap of 1 MiB here ended every original-source
27
- // segment longer than that on 6.1 (torrent-tv/meta#151).
24
+ // not continued by every FFmpeg: measured, 8.1.2 and 9.0.2 send a new
25
+ // request, while 5.1, 6.1, 7.0.2 (ffmpeg-static on Linux), 7.1 and 8.0 take
26
+ // it for a read error. A cap of 1 MiB here ended every original-source
27
+ // segment longer than that on 6.1 (torrent-tv/meta#151). The end of an
28
+ // admitted range is therefore only reached where FFmpeg needs nothing more,
29
+ // and a byte past it is refused, which from 7.1 on ends the run: every read
30
+ // FFmpeg makes, its stream search at open included, must be named by the
31
+ // container (torrent-tv/meta#165).
28
32
  const held = input.read(fileIndex, range.start, range.end, true);
29
33
  if (!held) {
30
34
  // FFmpeg asked for a byte the run was not given: the container named too
@@ -17,6 +17,8 @@ export function buildRunCommand({
17
17
  audioFile,
18
18
  inputUrl,
19
19
  audioInputUrl: audioInputUrlGiven,
20
+ inputOpenArgs = [],
21
+ audioInputOpenArgs = [],
20
22
  timeline,
21
23
  output,
22
24
  segmentFormat,
@@ -126,7 +128,7 @@ export function buildRunCommand({
126
128
  if (audioSeek > audioFileStartTime) {
127
129
  args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(audioSeek));
128
130
  }
129
- args.push("-i", audioInputUrl);
131
+ args.push(...audioInputOpenArgs, "-i", audioInputUrl);
130
132
  };
131
133
 
132
134
  // Whether an output `-ss` already cuts at the interval's start.
@@ -136,7 +138,7 @@ export function buildRunCommand({
136
138
  if (snappedKeyframe > sourceStartTime) {
137
139
  args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes, reorderDepth }, snappedKeyframe)));
138
140
  }
139
- args.push("-i", inputUrl);
141
+ args.push(...inputOpenArgs, "-i", inputUrl);
140
142
  pushAudioInput(snappedKeyframe);
141
143
  if (residualSeconds > 0 && !keyframeGrid) {
142
144
  args.push("-ss", ffmpegSeconds(residualSeconds));
@@ -146,7 +148,7 @@ export function buildRunCommand({
146
148
  if (seekAt > sourceStartTime) {
147
149
  args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(seekAt));
148
150
  }
149
- args.push("-i", inputUrl);
151
+ args.push(...inputOpenArgs, "-i", inputUrl);
150
152
  pushAudioInput(seekAt);
151
153
  }
152
154
  // An input seek lands on the picture's keyframe at or before the time asked
@@ -269,6 +271,10 @@ export function buildOriginalCommand(params) {
269
271
  const audio = sourceFor("audio") ?? primary;
270
272
  if (!primary) throw new Error("The selected source track is absent from original input.");
271
273
  const url = source => new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href;
274
+ // The container named the bytes FFmpeg's stream search reads for this much
275
+ // media time; the search is told the same, whatever its own default.
276
+ const openArgs = source => Number.isFinite(source.input.streamSearchSeconds)
277
+ ? ["-analyzeduration", String(Math.round(source.input.streamSearchSeconds * 1_000_000))] : [];
272
278
  const local = [...stretch, to + (to - last)];
273
279
  const localTimeline = { ...timeline, published: local,
274
280
  boundaries: local, sourceStartOf: () => primary.input.from ?? from + primary.timeShiftSeconds };
@@ -277,6 +283,7 @@ export function buildOriginalCommand(params) {
277
283
  reorderDepth: primary.input.selections.find(selection => selection.track.type === "video")?.track.reorderDepth ?? 0,
278
284
  inputFile: { startTime: primary.timeShiftSeconds }, audioFile: { startTime: audio.timeShiftSeconds },
279
285
  inputUrl: url(primary), audioInputUrl: audio !== primary && !audioOnly ? url(audio) : "",
286
+ inputOpenArgs: openArgs(primary), audioInputOpenArgs: openArgs(audio),
280
287
  audioSourceTrackIndex: audio.input.selections.find(selection => selection.track.type === "audio")?.index ?? 0 });
281
288
  const args = command.args;
282
289
  // The segment muxer otherwise shifts negative initial DTS to zero before
@@ -7,6 +7,7 @@
7
7
  * reassembled only for an AVI that states no index.
8
8
  */
9
9
 
10
+ import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
10
11
  import { Container } from "./Container.js";
11
12
  import { VideoTrack } from "../tracks/VideoTrack.js";
12
13
  import { AudioTrack } from "../tracks/AudioTrack.js";
@@ -162,7 +163,8 @@ export class AviContainer extends Container {
162
163
  * The bytes FFmpeg reads to produce `[from, to)` from the original file:
163
164
  * the declarations and the start of `movi` it probes, the index it loads at
164
165
  * open (`idx1` to the end of the file, or every OpenDML standard index), the
165
- * first packet of each stream it inspects, and the selected streams' packets
166
+ * first packet of each stream it inspects, the stretch its stream search
167
+ * reads from the start of `movi`, and the selected streams' packets
166
168
  * around the interval. Each range is held one input buffer of FFmpeg past its
167
169
  * last named byte, because that buffer is filled ahead of the packet parsed.
168
170
  */
@@ -182,7 +184,11 @@ export class AviContainer extends Container {
182
184
  const requested = tracks.filter(track => ["video", "audio"].includes(track.type));
183
185
  const picture = ContainerTrack.firstUsable(tracks, "video");
184
186
  const padded = ([start, end]) => [Math.max(0, start), Math.min(this.fileSize - 1, end + FFMPEG_INPUT_BUFFER_BYTES)];
185
- const ranges = [[0, movi.start + 3], ...index.indexRanges, ...index.firstPackets(),
187
+ // FFmpeg's stream search reads `movi` on from its start for its stated
188
+ // media time: every stream's packets in file order, from the first.
189
+ const search = index.mediaRanges({ from: 0, to: FFMPEG_STREAM_SEARCH_SECONDS, picture: null,
190
+ streams: requested.map(track => track.trackNumber) });
191
+ const ranges = [[0, movi.start + 3], ...index.indexRanges, ...index.firstPackets(), ...search,
186
192
  ...index.mediaRanges({ from: interval.from, to: interval.to, picture: picture?.trackNumber ?? null,
187
193
  streams: requested.map(track => track.trackNumber) })].map(padded);
188
194
  ranges.sort((left, right) => left[0] - right[0]);
@@ -192,7 +198,8 @@ export class AviContainer extends Container {
192
198
  if (previous && range[0] <= previous[1] + 1) previous[1] = Math.max(previous[1], range[1]);
193
199
  else union.push([...range]);
194
200
  }
195
- return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize };
201
+ return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize,
202
+ streamSearchSeconds: FFMPEG_STREAM_SEARCH_SECONDS };
196
203
  }
197
204
 
198
205
  packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
@@ -32,6 +32,7 @@
32
32
  * only way in.
33
33
  */
34
34
 
35
+ import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
35
36
  import { Container } from "./Container.js";
36
37
  import { VideoTrack } from "../tracks/VideoTrack.js";
37
38
  import { h264Configuration } from "./h264-configuration.js";
@@ -572,7 +573,8 @@ export class MatroskaContainer extends Container {
572
573
  }
573
574
 
574
575
  /** Original-file ranges from Cues alone, without reading media block headers.
575
- * FFmpeg needs the declarations and seek table as well as complete clusters.
576
+ * FFmpeg needs the declarations, the Clusters its stream search reads from the
577
+ * first one, and the seek table as well as complete clusters.
576
578
  * One preceding cluster retains preroll; one following cluster covers demux
577
579
  * lookahead beyond the presentation cut. No packet payload is parsed here.
578
580
  */
@@ -627,7 +629,11 @@ export class MatroskaContainer extends Container {
627
629
  if (cuesEnd === null) return { kind: "terminal", reason: "source-seek-index-is-unbounded" };
628
630
  const requestBytes = 1024 * 1024;
629
631
  const paddedEnd = end => Math.min(this.fileSize - 1, end + requestBytes);
630
- const ranges = [[0, paddedEnd(layout.firstClusterAt - 1)],
632
+ // FFmpeg's stream search reads on from the first Cluster for its stated
633
+ // media time and stops on the first packet past it, which lies before the
634
+ // first Cue later than that time; the padding holds its read-ahead.
635
+ const searchEnd = points[firstAt(points[0].seconds + FFMPEG_STREAM_SEARCH_SECONDS, true)];
636
+ const ranges = [[0, paddedEnd((searchEnd ? searchEnd.at : layout.segmentEnd) - 1)],
631
637
  [points[first].at, paddedEnd(mediaEnd)], [layout.cuesAt, paddedEnd(cuesEnd - 1)],
632
638
  [Math.max(0, this.fileSize - requestBytes), this.fileSize - 1]];
633
639
  // SeekHead may name top-level declarations after media clusters. Preserve
@@ -645,7 +651,8 @@ export class MatroskaContainer extends Container {
645
651
  if (previous && range[0] <= previous[1] + 1) previous[1] = Math.max(previous[1], range[1]);
646
652
  else union.push([...range]);
647
653
  }
648
- return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize };
654
+ return { kind: "result", from: interval.from, to: interval.to, ranges: union, fileLength: this.fileSize,
655
+ streamSearchSeconds: FFMPEG_STREAM_SEARCH_SECONDS };
649
656
  }
650
657
 
651
658
  async readPacketIndex(interval) {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * How far into a file FFmpeg reads, at open, to learn its streams
3
+ * (`avformat_find_stream_info`): until a stream has given it this much media
4
+ * time (`-analyzeduration`), unless every stream is known sooner.
5
+ *
6
+ * A container that names the bytes of an original-source run names this
7
+ * stretch from the first media too, and the run gives FFmpeg the same figure,
8
+ * so the read and the bytes held agree whatever FFmpeg's own default is. A
9
+ * read past the bytes held is refused, and from 7.1 on FFmpeg does not recover
10
+ * from that refusal: the run ends with nothing made (torrent-tv/meta#165).
11
+ *
12
+ * Five seconds is FFmpeg's own default for these containers
13
+ * (`5*AV_TIME_BASE` in avformat_find_stream_info), stated rather than chosen.
14
+ */
15
+ export const FFMPEG_STREAM_SEARCH_SECONDS = 5;
@@ -37,6 +37,10 @@ import {
37
37
  ZERO
38
38
  } from "./media-time.js";
39
39
 
40
+ // Match the media element's allowance for a range that starts a few frames
41
+ // after its current position (see server/public/domain/buffer-metrics.js).
42
+ const CLIENT_RANGE_START_TOLERANCE_SECONDS = 0.25;
43
+
40
44
  /**
41
45
  * The mean rate predicted over a horizon by the integral of the measured rate.
42
46
  * Polling does not change the last measured service rate.
@@ -148,7 +152,7 @@ export function predictPlaybackReadiness(input = {}) {
148
152
  const position = toSeconds(at);
149
153
  const remaining = Math.max(0, duration - position);
150
154
  const measuredRanges = reported.map((ranges, trackIndex) => ranges ?
151
- heldRanges(ranges, segmentsOf[trackIndex], unit) : null);
155
+ heldRanges(ranges, segmentsOf[trackIndex], unit, at) : null);
152
156
  const measured = measuredRanges.length > 0 && measuredRanges.every(Boolean);
153
157
  const heldEnd = measured ? earliest(...measuredRanges.map((ranges) => contiguousEnd(ranges, at))) : null;
154
158
  // The figure reported back is what the page said it holds; the widening by
@@ -511,13 +515,18 @@ function joinedPiece(served) {
511
515
  * @param {bigint} unit
512
516
  * @returns {import("./media-time.js").MediaRange[]}
513
517
  */
514
- function heldRanges(ranges, segments, unit) {
518
+ function heldRanges(ranges, segments, unit, position) {
515
519
  const pieces = segments.flatMap((segment) => segment.ranges ?? []);
516
520
  const zero = mediaTime(0n, unit);
517
521
  const error = rescale(REPORTED_TIME_ERROR, unit);
522
+ const startTolerance = add(position, rescale(fromSeconds(CLIENT_RANGE_START_TOLERANCE_SECONDS), unit));
518
523
  const held = [];
519
524
  for (const range of ranges) {
520
- const start = latest(zero, subtract(rescale(range.start, unit), error));
525
+ const reportedStart = rescale(range.start, unit);
526
+ let start = latest(zero, subtract(reportedStart, error));
527
+ if (compare(reportedStart, position) > 0 && compare(reportedStart, startTolerance) <= 0) {
528
+ start = position;
529
+ }
521
530
  const end = add(rescale(range.end, unit), error);
522
531
  const inside = pieces.filter((piece) => contains([{ start, end }], piece.start, piece.end));
523
532
  addMediaRange(held, { start, end, frame: latest(zero, ...inside.map(({ frame }) => frame)) });