@torrent-tv/proxy 2.87.18 → 2.87.19

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.87.19
2
+
3
+ - **Fix**: Return shared source bytes after whole-file assembly and retain the buffer held by concurrent readers instead of replacing it; fail malformed fragments explicitly rather than skipping source bytes.
4
+ - **Fix**: Establish decode timestamp ordering in a private preceding cut before a copied-video restart, preserving leading reordered frames at the requested boundary.
5
+ - **Fix**: Carry variable-rate video presentation across cuts only when decode timestamps prove continuity, and predict unproduced cuts in the measured track clock within the readiness integral.
6
+
1
7
  ## 2.87.18
2
8
 
3
9
  - **Fix**: Interpret variable-rate video sample presentation holds and movie-clock resolution as media coverage; project prepared intervals onto the browser's reported HLS timestamp offsets per track.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.18",
3
+ "version": "2.87.19",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -832,7 +832,12 @@ export class EncodeRuns {
832
832
  // Why this encoder exists, recorded with its argument list. It used to be
833
833
  // handed to a separate `start` call; there is no separate call now.
834
834
  because,
835
- onClosed: (name) => this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat),
835
+ onClosed: (name) => {
836
+ const index = session.segmentFormat.segmentIndexFromName(
837
+ session.segmentFormat.servedNameOf?.(name) ?? name);
838
+ if (Number.isInteger(index) && index < safeIndex) return null;
839
+ return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat);
840
+ },
836
841
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
837
842
  });
838
843
  // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
@@ -358,6 +358,11 @@ export function buildRunCommand({
358
358
  // the cut points and, when re-encoding, as the keyframes to force — one
359
359
  // list, so the two cannot drift apart.
360
360
  const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
361
+ // Matroska seeks begin without the preceding decode timestamps. Copy one
362
+ // preceding cut privately so reordered frames have established DTS before
363
+ // the first requested cut. It is never published over another run's output.
364
+ const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
365
+ ? safeIndex - 1 : safeIndex;
361
366
  // A COPY is cut by this list whatever grid it ended up on. Even when no
362
367
  // keyframe index could be read and the boundaries are a plain grid, saying
363
368
  // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
@@ -382,7 +387,7 @@ export function buildRunCommand({
382
387
  // playlist and its own cuts agree from the start. What they may not do is
383
388
  // move the cuts of a session whose playlist is already being read.
384
389
  const gridCutTimes = explicitTimes && (!transcodeVideo || timeline.cutGrid === "keyframe")
385
- ? segmentCutTimesFrom(publishedGridFor(timeline), safeIndex)
390
+ ? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
386
391
  : null;
387
392
  // Cut times are stated on the grid, for both branches.
388
393
  //
@@ -443,9 +448,11 @@ export function buildRunCommand({
443
448
  // back on to reach it; on the uniform grid it is a plain offset. This
444
449
  // follows the GRID, not whether the video is re-encoded — a variant cut on
445
450
  // the source's keyframes has to seek to them like the copy it accompanies.
451
+ const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
452
+ publishedStartTime(timeline, inputIndex, segmentDurationSec);
446
453
  const seekSeconds = timeline.cutGrid === "keyframe"
447
- ? startSeconds + sourceStartTime
448
- : startSeconds;
454
+ ? inputStartSeconds + sourceStartTime
455
+ : inputStartSeconds;
449
456
  // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
450
457
  // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
451
458
  // not the container's own on-the-fly seek/index) and trim the short
@@ -477,7 +484,7 @@ export function buildRunCommand({
477
484
  // The search stays for a grid restored without its source clock, which is the
478
485
  // only case that has nothing to carry.
479
486
  const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
480
- ? timeline.sourceStartOf(safeIndex)
487
+ ? timeline.sourceStartOf(inputIndex)
481
488
  : null;
482
489
  const snappedKeyframe = Number.isFinite(carriedKeyframe)
483
490
  ? carriedKeyframe
@@ -689,7 +696,7 @@ export function buildRunCommand({
689
696
  "-segment_time_delta",
690
697
  "0.05",
691
698
  "-segment_start_number",
692
- String(safeIndex),
699
+ String(inputIndex),
693
700
  // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
694
701
  //
695
702
  // Measured on the addon host 2026-09-05: a name appears in this list when
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import {
14
+ continuePresentationRanges,
14
15
  rebaseSegmentDecodeTimes,
15
16
  neutralizeEmptyEdits,
16
17
  readPresentationRanges,
@@ -287,7 +288,8 @@ export const fmp4Format = {
287
288
  return readPresentationRanges(Buffer.concat([initBytes, served]));
288
289
  },
289
290
  clientMediaRanges(ranges, context) {
290
- return translatePresentationRanges(ranges, context.initBytes, context.timestampOffsetSeconds);
291
+ return translatePresentationRanges(continuePresentationRanges(ranges, context.nextRanges),
292
+ context.initBytes, context.timestampOffsetSeconds);
291
293
  },
292
294
 
293
295
  /**
@@ -53,7 +53,7 @@ import { mpegtsFormat } from "./mpegts.js";
53
53
  * { start: number, end: number }[] | undefined} [readMediaRanges] - Actual
54
54
  * served presentation intervals, or undefined until their header is available.
55
55
  * @property {(ranges: { start: number, end: number }[], context: {
56
- * initBytes: Buffer | null, timestampOffsetSeconds?: number }) =>
56
+ * initBytes: Buffer | null, timestampOffsetSeconds?: number, nextRanges?: Array<{ start: number, end: number }> }) =>
57
57
  * { start: number, end: number }[]} [clientMediaRanges] - Project parsed
58
58
  * presentation intervals onto the clock reported by this viewer's player.
59
59
  */
@@ -176,6 +176,7 @@ export function readPresentationRanges(raw) {
176
176
  const scales = readTrackTimescales(raw);
177
177
  const edits = readTrackEditOffsets(raw);
178
178
  const byTrack = new Map();
179
+ const decodeRanges = new Map();
179
180
  const defaultDurations = new Map();
180
181
  let trackId = null;
181
182
  let decodeTime = 0;
@@ -221,6 +222,10 @@ export function readPresentationRanges(raw) {
221
222
  if (!(duration > 0)) return;
222
223
  const position = (decodeTime + composition) / scale + (edits.get(trackId) ?? 0);
223
224
  ranges.push({ start: position, end: position + duration / scale });
225
+ const decode = decodeRanges.get(trackId) ?? [];
226
+ decode.push({ start: decodeTime / scale + (edits.get(trackId) ?? 0),
227
+ end: (decodeTime + duration) / scale + (edits.get(trackId) ?? 0) });
228
+ decodeRanges.set(trackId, decode);
224
229
  decodeTime += duration;
225
230
  }
226
231
  byTrack.set(trackId, ranges);
@@ -243,7 +248,10 @@ export function readPresentationRanges(raw) {
243
248
  // Movie edits quantize independent pieces to movie ticks. Preserve that
244
249
  // declared resolution at joins; it is not a playback buffer threshold.
245
250
  for (const range of merged) range.end += precision;
246
- return { id, ranges: merged };
251
+ const decoded = decodeRanges.get(id);
252
+ return { id, ranges: merged, kind: kinds.get(id), precision,
253
+ decodeStart: Math.min(...decoded.map(({ start }) => start)),
254
+ decodeEnd: Math.max(...decoded.map(({ end }) => end)) };
247
255
  });
248
256
  // A multiplexed segment is playable only where every declared track exists.
249
257
  const result = intersectPresentationTracks(mergedTracks);
@@ -251,6 +259,26 @@ export function readPresentationRanges(raw) {
251
259
  return result;
252
260
  }
253
261
 
262
+ /** A video frame can cross a file boundary when decoding remains continuous. */
263
+ export function continuePresentationRanges(ranges, nextRanges) {
264
+ const tracks = presentationTracks.get(ranges);
265
+ const next = presentationTracks.get(nextRanges);
266
+ if (!tracks || !next) return ranges;
267
+ const continued = tracks.map((track) => {
268
+ const following = next.find(({ id }) => id === track.id);
269
+ const held = track.ranges.map((range) => ({ ...range }));
270
+ if (track.kind === "vide" && following && held.length > 0 && following.ranges.length > 0 &&
271
+ Math.abs(track.decodeEnd - following.decodeStart) <= track.precision + following.precision +
272
+ Number.EPSILON * Math.max(1, following.decodeStart) * 8) {
273
+ held.at(-1).end = Math.max(held.at(-1).end, following.ranges[0].start);
274
+ }
275
+ return { ...track, ranges: held };
276
+ });
277
+ const result = intersectPresentationTracks(continued);
278
+ presentationTracks.set(result, continued);
279
+ return result;
280
+ }
281
+
254
282
  function intersectPresentationTracks(tracks) {
255
283
  return tracks.map(({ ranges }) => ranges).reduce((common, ranges) => common.flatMap((left) => ranges
256
284
  .map((right) => ({ start: Math.max(left.start, right.start), end: Math.min(left.end, right.end) }))
@@ -566,10 +566,12 @@ export class ViewerRequests {
566
566
  const trackRates = this.#trackRateReadings(measurement, output, now);
567
567
  const timeline = output.timeline;
568
568
  const trackName = output === session ? "video" : "audio";
569
- const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
570
- const ranges = this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
569
+ const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
570
+ this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
571
571
  startSeconds: timeline.publishedStartOf(index)
572
- });
572
+ }));
573
+ const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
574
+ const ranges = nativeRanges[index];
573
575
  return {
574
576
  index,
575
577
  startSeconds: timeline.publishedStartOf(index),
@@ -577,6 +579,7 @@ export class ViewerRequests {
577
579
  mediaRanges: ranges && output.segmentFormat?.clientMediaRanges ?
578
580
  output.segmentFormat.clientMediaRanges(ranges, {
579
581
  initBytes: this.#host.segmentStore.initOf(output.outputKey),
582
+ nextRanges: nativeRanges[index + 1],
580
583
  timestampOffsetSeconds: viewerReading?.timestampOffsets?.[trackName]
581
584
  }) : ranges
582
585
  };
@@ -128,7 +128,7 @@ export function predictPlaybackReadiness(input = {}) {
128
128
  curve: rateCurve(track.readings, now),
129
129
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
130
130
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
131
- segments: track.segments
131
+ segments: predictedMediaSegments(track.segments)
132
132
  .filter((segment) => Number.isInteger(segment?.index) &&
133
133
  Number.isFinite(segment?.startSeconds) && Number.isFinite(segment?.endSeconds) &&
134
134
  segment.endSeconds > segment.startSeconds && segment.endSeconds > position)
@@ -388,6 +388,25 @@ export function predictPlaybackReadiness(input = {}) {
388
388
  "minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
389
389
  }
390
390
 
391
+ /** Predict only unknown cuts in the same clock as the measured track. */
392
+ function predictedMediaSegments(segments) {
393
+ const anchors = segments.filter(({ mediaRanges }) => mediaRanges?.length > 0);
394
+ return segments.map((segment) => {
395
+ if (segment.mediaRanges !== undefined && segment.mediaRanges !== null || anchors.length === 0) return segment;
396
+ const previous = anchors.findLast(({ index }) => index < segment.index);
397
+ const next = anchors.find(({ index }) => index > segment.index);
398
+ const nominalLeft = previous?.endSeconds ?? next.startSeconds;
399
+ const actualLeft = previous?.mediaRanges.at(-1).end ?? next.mediaRanges[0].start;
400
+ const nominalRight = next?.startSeconds ?? nominalLeft;
401
+ const actualRight = next?.mediaRanges[0].start ?? actualLeft;
402
+ const scale = nominalRight > nominalLeft ? (actualRight - actualLeft) / (nominalRight - nominalLeft) : 1;
403
+ return { ...segment, mediaRanges: [{
404
+ start: actualLeft + (segment.startSeconds - nominalLeft) * scale,
405
+ end: actualLeft + (segment.endSeconds - nominalLeft) * scale
406
+ }] };
407
+ });
408
+ }
409
+
391
410
  function rateCurve(readings, now) {
392
411
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
393
412
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
@@ -933,13 +933,19 @@ export class SharedPieceStore {
933
933
  // THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
934
934
  // allocated and 676 still alive, and the process was killed at 4.37 GB.
935
935
  const displaced = this.#buffers.get(index);
936
- this.#buffers.set(index, buffer);
937
936
  if (displaced !== undefined && displaced !== buffer) {
938
937
  this.#counters.blocksDisplaced += 1;
939
- this.#returnBlock(displaced);
938
+ // A reader may still hold the existing buffer pinned. Verified bytes for
939
+ // this index are identical, so retain that buffer and return the duplicate.
940
+ this.#returnBlock(buffer);
941
+ this.#lru.touch(index);
942
+ this.#noteProgress();
943
+ return displaced;
940
944
  }
945
+ this.#buffers.set(index, buffer);
941
946
  this.#lru.touch(index);
942
947
  this.#noteProgress();
948
+ return buffer;
943
949
  }
944
950
 
945
951
  /**
@@ -1394,12 +1400,9 @@ export class SharedPieceStore {
1394
1400
  return null;
1395
1401
  }
1396
1402
  this.#counters.fromWholeFile += 1;
1397
- const release = await this.#claimSlotOrNull();
1398
- if (release === null) {
1399
- // No block to put it in. The caller gets the bytes anyway; what it
1400
- // loses is only that the next read pays for this one again.
1401
- return whole;
1402
- }
1403
+ // Fragment readers require shared memory, including when the bytes now
1404
+ // live in an assembled file. Wait for the same reservation as disk reads.
1405
+ const release = await this.#claimSlot();
1403
1406
  try {
1404
1407
  return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
1405
1408
  } finally {
@@ -1432,11 +1435,11 @@ export class SharedPieceStore {
1432
1435
  this.#returnBlock(target);
1433
1436
  throw error;
1434
1437
  }
1435
- this.#registerPiece(index, target);
1438
+ const registered = this.#registerPiece(index, target);
1436
1439
  this.#counters.fromDisk += 1;
1437
1440
  this.#counters.revivals += 1;
1438
1441
  this.#noteRevival(index);
1439
- return target;
1442
+ return registered;
1440
1443
  } finally {
1441
1444
  release?.();
1442
1445
  releaseDisk();
@@ -149,18 +149,18 @@ export class TorrentWorkerClient {
149
149
  switch (message?.type) {
150
150
  case Event.FRAGMENT: {
151
151
  const buffer = message.buffer;
152
- if (!buffer) {
153
- // No buffer means no way to read the fragment; confirm it so the
154
- // worker is not left waiting, and let the read end short.
155
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
156
- break;
157
- }
158
- if (message.offset + message.length > buffer.byteLength) {
159
- logger.warn(
160
- `torrent-worker: fragment ${message.offset}+${message.length} lies outside ` +
161
- `its buffer of ${buffer.byteLength}B (read ${message.id}) — ending the read short`
162
- );
152
+ if (!buffer || !Number.isInteger(message.offset) || message.offset < 0 ||
153
+ !Number.isInteger(message.length) || message.length <= 0 ||
154
+ message.offset + message.length > buffer.byteLength) {
155
+ const error = new Error(`Invalid source fragment ${message.offset}+${message.length} ` +
156
+ `in a buffer of ${buffer?.byteLength ?? 0}B (read ${message.id}).`);
157
+ logger.warn(`torrent-worker: ${error.message}`);
158
+ this.#fragmentReaders.get(message.id)?.fail(error);
159
+ this.#fragmentReaders.delete(message.id);
160
+ this.#reads.get(message.id)?.fail(error);
161
+ this.#reads.delete(message.id);
163
162
  this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
163
+ void this.#caller.call(Command.CANCEL_READ, { readId: message.id }).catch(() => undefined);
164
164
  break;
165
165
  }
166
166
  // A sequential read walks the file forwards, so each fragment either
@@ -1,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
4
- import { readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
4
+ import { continuePresentationRanges, readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
5
5
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
@@ -127,6 +127,17 @@ test("client clock projection accounts for shared edits and audio initPTS", () =
127
127
  assert.deepEqual(projected, video);
128
128
  });
129
129
 
130
+ test("a variable-rate frame crosses a cut only with continuous decode samples", () => {
131
+ const first = readPresentationRanges(Buffer.concat([init(0, 0), fragment(0, 0)]));
132
+ const continuous = readPresentationRanges(Buffer.concat([init(0, 0), fragment(1000, 72000)]));
133
+ assert.equal(continuePresentationRanges(first, continuous)[0].end, continuous[0].start);
134
+ const missing = readPresentationRanges(Buffer.concat([init(0, 0), fragment(24000, 72000)]));
135
+ assert.deepEqual(continuePresentationRanges(first, missing), first);
136
+ const audioFirst = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), fragment(0, 0)]));
137
+ const audioNext = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), fragment(1000, 72000)]));
138
+ assert.deepEqual(continuePresentationRanges(audioFirst, audioNext), audioFirst);
139
+ });
140
+
130
141
  test("a viewer retains only finite per-track player clock offsets", () => {
131
142
  const viewer = new Viewer("clock-reading", 1);
132
143
  viewer.report({ timestampOffsets: { video: -0.083, audio: 0, media: 400 } }, 2);
@@ -148,6 +148,28 @@ test("a revival holds disk bytes across the asynchronous memory reservation", as
148
148
  }
149
149
  });
150
150
 
151
+ test("an assembled-file revival returns shared bytes and preserves a concurrent pinned reader", async () => {
152
+ const disk = makeDisk();
153
+ const store = new SharedPieceStore(CHUNK, {
154
+ length: CHUNK * 16, memoryBytes: CHUNK * 4, disk,
155
+ readPieceElsewhere: async ({ index }) => piece(index), name: "whole-file-read"
156
+ });
157
+ try {
158
+ store.pin(0);
159
+ const [first, second] = await Promise.all([store.reside(0), store.reside(0)]);
160
+ assert.ok(first.buffer instanceof SharedArrayBuffer);
161
+ assert.equal(first.buffer, second.buffer);
162
+ await put(store, 1);
163
+ await put(store, 2);
164
+ assert.deepEqual(Buffer.from(first.buffer, first.offset, first.length), piece(0));
165
+ assert.equal(store.locate(0).buffer, first.buffer);
166
+ assert.equal(store.stats().outstanding, 0);
167
+ store.unpin(0);
168
+ } finally {
169
+ await new Promise((resolve) => store.destroy(resolve));
170
+ }
171
+ });
172
+
151
173
  /**
152
174
  * Wait until a condition holds, rather than for a chosen interval — a test that
153
175
  * sleeps samples, it does not check (roadmap item 54).
@@ -39,6 +39,17 @@ test("finds the first safe start from segments delivered before playback", () =>
39
39
  assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
40
40
  });
41
41
 
42
+ test("future cuts follow measured track time without inventing a permanent clock gap", () => {
43
+ const state = input({ bufferedAheadSeconds: 3.9, reserveSeconds: 0 });
44
+ state.tracks[0].clientRanges = [{ start: 0, end: 3.9 }];
45
+ state.tracks[0].segments[0].mediaRanges = [{ start: 0, end: 3.9 }];
46
+ const forecast = predictPlaybackReadiness(state);
47
+ assert.notEqual(forecast.reason, "no-safe-start-found");
48
+ state.tracks[0].segments[1].mediaRanges = [{ start: 4, end: 8 }];
49
+ state.tracks[0].readySegmentIndices = [0, 1];
50
+ assert.equal(predictPlaybackReadiness(state).reason, "no-safe-start-found");
51
+ });
52
+
42
53
  test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
43
54
  const now = 10_000;
44
55
  const forecast = predictPlaybackReadiness({
@@ -761,8 +761,7 @@ test("a run on the keyframe grid is given no trim to apply", () => {
761
761
  videoEncoder: { name: "libx264" },
762
762
  segmentDurationSec: SEGMENT_SECONDS
763
763
  });
764
- // Exactly one `-ss`, and it is the input seek: the trim is not there to be
765
- // inherited by the cuts.
764
+ // One input seek establishes decode order in the preceding private cut.
766
765
  assert.equal(
767
766
  args.filter((one) => one === "-ss").length,
768
767
  1,
@@ -770,9 +769,10 @@ test("a run on the keyframe grid is given no trim to apply", () => {
770
769
  );
771
770
  const seek = Number(args[args.indexOf("-ss") + 1]);
772
771
  assert.ok(
773
- Math.abs(seek - grid.sourceTimes[index]) < 0.2,
774
- `run at #${index} seeks to ${seek}, not to its own keyframe ${grid.sourceTimes[index]}`
772
+ Math.abs(seek - grid.sourceTimes[index - 1]) < 0.2,
773
+ `run at #${index} seeks to ${seek}, not to the preceding keyframe ${grid.sourceTimes[index - 1]}`
775
774
  );
775
+ assert.equal(Number(args[args.indexOf("-segment_start_number") + 1]), index - 1);
776
776
  }
777
777
  });
778
778