@torrent-tv/proxy 2.87.13 → 2.87.14

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,7 @@
1
+ ## 2.87.14
2
+
3
+ - **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
4
+
1
5
  ## 2.87.13
2
6
 
3
7
  - **Fix**: Include distinct active piece reads in memory demand and preserve their slots immediately, so reads without priority-map ranges cannot deadlock the torrent piece store.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.13",
3
+ "version": "2.87.14",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -81,7 +81,7 @@ export class RateTrend {
81
81
  * @param {number} input.durationSeconds
82
82
  * @param {number} input.bufferedAheadSeconds
83
83
  * @param {number} input.bufferLimitSeconds
84
- * @param {number} input.reserveSeconds
84
+ * @param {number} input.reserveSeconds - Measured source-stall coverage, in media seconds.
85
85
  * @param {number} input.lookaheadSeconds
86
86
  * @param {number} input.now
87
87
  * @param {boolean} [input.requiredAudio]
@@ -110,12 +110,9 @@ export function predictPlaybackReadiness(input = {}) {
110
110
  if (remaining <= buffered) {
111
111
  return result(true, 0, buffered, reserve, 0, "client-buffer-covers-end", 0);
112
112
  }
113
- if (!(duration > 0) || !(capacity > 0) || !(reserve > 0) || tracks.length === 0) {
113
+ if (!(duration > 0) || !(capacity > 0) || tracks.length === 0) {
114
114
  return result(false, null, buffered, reserve, null, "incomplete-state", 0);
115
115
  }
116
- if (reserve > capacity) {
117
- return result(false, null, buffered, reserve, null, "reserve-exceeds-client-capacity", 0);
118
- }
119
116
  const requiredTracks = input.requiredAudio === true ? 2 : 1;
120
117
  if (tracks.length < requiredTracks) {
121
118
  return result(false, null, buffered, reserve, null, "separate-audio-not-observed", 0);
@@ -166,7 +163,7 @@ export function predictPlaybackReadiness(input = {}) {
166
163
 
167
164
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
168
165
  const state = trackState[trackIndex];
169
- const { track, ready, segments } = state;
166
+ const { track, segments } = state;
170
167
  const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
171
168
  for (const segment of segments) {
172
169
  if (segment.endSeconds <= position + buffered) {
@@ -277,6 +274,7 @@ export function predictPlaybackReadiness(input = {}) {
277
274
  const trackFinish = trackState.map(() => 0);
278
275
  let transferFinish = 0;
279
276
  const completions = [];
277
+ const productions = [];
280
278
  for (const job of jobs) {
281
279
  const { track, trackIndex, segment, ahead, sizeBits, encodeWork, sourceIntervals } = job;
282
280
  if (prefillOnly && (ahead > prefillReach || segment.endSeconds - position > capacity)) {
@@ -322,7 +320,14 @@ export function predictPlaybackReadiness(input = {}) {
322
320
  if (!job.produced) {
323
321
  trackFinish[trackIndex] = encodedAt;
324
322
  }
325
- const producedAt = Math.max(sourceReadyAtLatest, encodedAt);
323
+ const producedAt = job.produced ? 0 : Math.max(sourceReadyAtLatest, encodedAt);
324
+ productions.push({
325
+ at: producedAt,
326
+ sourceReadyAt: sourceReadyAtLatest,
327
+ trackIndex,
328
+ segment,
329
+ requiresSource: sourceIntervals.size > 0
330
+ });
326
331
  const roomAt = segment.endSeconds - position <= capacity
327
332
  ? 0
328
333
  : startDelaySeconds + segment.endSeconds - position - capacity;
@@ -339,12 +344,16 @@ export function predictPlaybackReadiness(input = {}) {
339
344
  ? completions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
340
345
  segment.endSeconds - position <= capacity)
341
346
  : completions;
342
- const safety = isSafeSchedule(usefulCompletions, trackState, position, duration,
347
+ const usefulProductions = prefillOnly
348
+ ? productions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
349
+ segment.endSeconds - position <= capacity)
350
+ : productions;
351
+ const safety = isSafeSchedule(usefulCompletions, usefulProductions, trackState, position, duration,
343
352
  buffered, capacity, reserve, startDelaySeconds);
344
353
  return { safe: safety.safe,
345
354
  bufferedAtStart: safety.bufferedAtStart,
346
- finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0),
347
- completions: usefulCompletions };
355
+ neededSeconds: safety.neededSeconds,
356
+ finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
348
357
  };
349
358
 
350
359
  const prefillReach = Math.min(capacity, lookahead, remaining);
@@ -353,7 +362,8 @@ export function predictPlaybackReadiness(input = {}) {
353
362
  if (!Number.isFinite(maximumUsefulDelay)) {
354
363
  return result(false, null, buffered, reserve, null, "prefill-cannot-complete", preparedSegments);
355
364
  }
356
- if (schedule(0).safe) {
365
+ const immediate = schedule(0);
366
+ if (immediate.safe) {
357
367
  return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
358
368
  }
359
369
  const searchLimit = Math.max(maximumUsefulDelay, rateRecoveryDelay);
@@ -396,7 +406,7 @@ export function predictPlaybackReadiness(input = {}) {
396
406
  }
397
407
  }
398
408
  const readiness = schedule(high);
399
- return result(false, high, buffered, reserve, Math.max(0, reserve - buffered),
409
+ return result(false, high, buffered, reserve, immediate.neededSeconds,
400
410
  "minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
401
411
  }
402
412
 
@@ -508,53 +518,104 @@ function averageBitsPerMediaSecond(track, segments, ready) {
508
518
  return finiteNonNegative(track.bitsPerMediaSecond);
509
519
  }
510
520
 
511
- function isSafeSchedule(completions, trackState, position, duration, buffered, capacity, reserve, startDelay) {
512
- const ranges = trackState.map(() => [{ start: position, end: position + buffered }]);
513
- const ordered = [...completions]
521
+ function isSafeSchedule(completions, productions, trackState, position, duration, buffered, capacity, reserve,
522
+ startDelay) {
523
+ // Client continuity is constrained by its buffer limit; source-stall reserve
524
+ // is covered by the larger union of client data and output already prepared here.
525
+ const clientRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
526
+ const preparedRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
527
+ const orderedCompletions = [...completions]
514
528
  .filter(({ at }) => Number.isFinite(at))
515
529
  .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
530
+ const orderedProductions = [...productions]
531
+ .filter(({ at }) => Number.isFinite(at))
532
+ .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
533
+ const reserveUntil = trackState.map(() => Number.NEGATIVE_INFINITY);
534
+ // The measured reserve applies only until this track's last required source
535
+ // interval has arrived; encoding and client delivery stay in the schedule.
536
+ for (const production of productions) {
537
+ if (production.requiresSource) {
538
+ reserveUntil[production.trackIndex] = Math.max(reserveUntil[production.trackIndex],
539
+ production.sourceReadyAt);
540
+ }
541
+ }
516
542
  let completionIndex = 0;
543
+ let productionIndex = 0;
517
544
 
518
545
  const addThrough = (time) => {
519
- while (completionIndex < ordered.length && ordered[completionIndex].at <= time) {
520
- const { trackIndex, segment } = ordered[completionIndex];
521
- addRange(ranges[trackIndex], segment.startSeconds, segment.endSeconds);
546
+ while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
547
+ const { trackIndex, segment } = orderedProductions[productionIndex];
548
+ addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
549
+ productionIndex += 1;
550
+ }
551
+ while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
552
+ const { trackIndex, segment } = orderedCompletions[completionIndex];
553
+ addRange(clientRanges[trackIndex], segment.startSeconds, segment.endSeconds);
554
+ addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
522
555
  completionIndex += 1;
523
556
  }
524
557
  };
525
- const safeAt = (time) => {
558
+ const safeAt = (time, checkClient = true) => {
526
559
  const playerPosition = position + Math.max(0, time - startDelay);
527
- const available = Math.min(capacity, ...ranges.map((trackRanges) =>
560
+ if (playerPosition >= duration) {
561
+ return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
562
+ }
563
+ const available = Math.min(capacity, ...clientRanges.map((trackRanges) =>
528
564
  Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
529
- const required = Math.min(reserve, Math.max(0, duration - playerPosition));
530
- return { safe: available >= required, available };
565
+ let neededSeconds = 0;
566
+ let stockSafe = true;
567
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
568
+ const required = time <= reserveUntil[trackIndex]
569
+ ? Math.min(reserve, Math.max(0, duration - playerPosition))
570
+ : 0;
571
+ const preparedAhead = Math.max(0,
572
+ contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
573
+ const deficit = Math.max(0, required - preparedAhead);
574
+ neededSeconds = Math.max(neededSeconds, deficit);
575
+ stockSafe &&= deficit === 0;
576
+ }
577
+ return {
578
+ safe: (!checkClient || available > 0) && stockSafe,
579
+ stockSafe,
580
+ available,
581
+ neededSeconds
582
+ };
531
583
  };
532
584
 
533
585
  addThrough(startDelay);
534
586
  const atStart = safeAt(startDelay);
535
587
  if (!atStart.safe) {
536
- return { safe: false, bufferedAtStart: atStart.available };
537
- }
538
-
539
- while (completionIndex < ordered.length) {
540
- const time = ordered[completionIndex].at;
541
- if (time > startDelay) {
542
- const beforeArrival = safeAt(time);
543
- if (!beforeArrival.safe) {
544
- return { safe: false, bufferedAtStart: atStart.available };
545
- }
588
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
589
+ }
590
+
591
+ const playbackEnd = startDelay + Math.max(0, duration - position);
592
+ const playbackBoundaries = [position + buffered,
593
+ ...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
594
+ .filter((boundary) => boundary > position && boundary < duration)
595
+ .map((boundary) => startDelay + boundary - position);
596
+ const sourceReadyTimes = productions
597
+ .filter(({ requiresSource }) => requiresSource)
598
+ .map(({ sourceReadyAt }) => sourceReadyAt);
599
+ const eventTimes = [...orderedCompletions.map(({ at }) => at),
600
+ ...orderedProductions.map(({ at }) => at), ...playbackBoundaries, ...sourceReadyTimes]
601
+ .filter((time) => Number.isFinite(time) && time > startDelay && time < playbackEnd)
602
+ .sort((left, right) => left - right)
603
+ .filter((time, index, all) => index === 0 || time !== all[index - 1]);
604
+
605
+ for (const time of eventTimes) {
606
+ const beforeArrival = safeAt(time, false);
607
+ if (!beforeArrival.stockSafe) {
608
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: beforeArrival.neededSeconds };
546
609
  }
547
610
  addThrough(time);
548
- if (time > startDelay) {
549
- const afterArrival = safeAt(time);
550
- if (!afterArrival.safe) {
551
- return { safe: false, bufferedAtStart: atStart.available };
552
- }
611
+ const afterArrival = safeAt(time);
612
+ if (!afterArrival.safe) {
613
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: afterArrival.neededSeconds };
553
614
  }
554
615
  }
555
616
 
556
- const atEnd = safeAt(startDelay + Math.max(0, duration - position));
557
- return { safe: atEnd.safe, bufferedAtStart: atStart.available };
617
+ const atEnd = safeAt(playbackEnd);
618
+ return { safe: atEnd.safe, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
558
619
  }
559
620
 
560
621
  function addRange(ranges, start, end) {
@@ -36,7 +36,50 @@ test("finds the first safe start from segments delivered before playback", () =>
36
36
 
37
37
  assert.equal(forecast.ready, false);
38
38
  assert.equal(forecast.reason, "minimum-safe-delay");
39
- assert.ok(Math.abs(forecast.delaySeconds - 3) < 1e-8);
39
+ assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
40
+ });
41
+
42
+ test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
43
+ const now = 10_000;
44
+ const forecast = predictPlaybackReadiness({
45
+ now,
46
+ positionSeconds: 0,
47
+ durationSeconds: 12,
48
+ bufferedAheadSeconds: 2,
49
+ bufferLimitSeconds: 4,
50
+ reserveSeconds: 6,
51
+ lookaheadSeconds: 12,
52
+ sources: [{
53
+ id: "source",
54
+ complete: false,
55
+ bytesPerMediaSecond: 1,
56
+ readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }]
57
+ }],
58
+ tracks: [{
59
+ id: "video",
60
+ sourceIds: ["source"],
61
+ processedSeconds: 8,
62
+ bitsPerMediaSecond: 80,
63
+ readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }],
64
+ segments: [
65
+ { index: 0, startSeconds: 0, endSeconds: 2 },
66
+ { index: 1, startSeconds: 2, endSeconds: 4 },
67
+ { index: 2, startSeconds: 4, endSeconds: 6 },
68
+ { index: 3, startSeconds: 6, endSeconds: 8 },
69
+ { index: 4, startSeconds: 8, endSeconds: 10 },
70
+ { index: 5, startSeconds: 10, endSeconds: 12 }
71
+ ],
72
+ readySegmentIndices: [0, 1, 2, 3],
73
+ segmentSizesBytes: new Map([[0, 20], [1, 20], [2, 20], [3, 20], [4, 20], [5, 20]])
74
+ }],
75
+ linkReadings: [{ at: now - 1_000, value: 80_000 }, { at: now, value: 80_000 }]
76
+ });
77
+
78
+ assert.equal(forecast.ready, true);
79
+ assert.equal(forecast.reason, "trajectory-safe-now");
80
+ assert.equal(forecast.reserveSeconds, 6);
81
+ assert.equal(forecast.bufferedSeconds, 2);
82
+ assert.equal(forecast.preparedSegments, 4);
40
83
  });
41
84
 
42
85
  test("overlaps source reads and encoding, then waits for the slower stage and delivery", () => {
@@ -53,7 +96,7 @@ test("overlaps source reads and encoding, then waits for the slower stage and de
53
96
  const forecast = predictPlaybackReadiness(input({ sources: [source], tracks: [track] }));
54
97
 
55
98
  assert.equal(forecast.ready, false);
56
- assert.ok(Math.abs(forecast.delaySeconds - 3) < 1e-8);
99
+ assert.ok(Math.abs(forecast.delaySeconds - 2) < 1e-8);
57
100
  });
58
101
 
59
102
  test("does not produce an ETA without a client-link measurement", () => {
@@ -177,7 +220,7 @@ test("downloads an overlapping source interval once when two tracks use the same
177
220
 
178
221
  assert.equal(forecast.ready, false);
179
222
  assert.equal(forecast.reason, "minimum-safe-delay");
180
- assert.ok(Math.abs(forecast.delaySeconds - 2.048) < 1e-8);
223
+ assert.ok(Math.abs(forecast.delaySeconds - 2.032) < 1e-8);
181
224
  });
182
225
 
183
226
  test("downloads sources on separate torrents concurrently", () => {
@@ -319,5 +362,5 @@ test("finds a safe interval when rising encode speed and falling link speed make
319
362
  assert.equal(forecast.ready, false);
320
363
  assert.equal(forecast.reason, "minimum-safe-delay");
321
364
  assert.ok(Number.isFinite(forecast.delaySeconds));
322
- assert.ok(forecast.delaySeconds > 7 && forecast.delaySeconds < 10);
365
+ assert.ok(forecast.delaySeconds > 4 && forecast.delaySeconds < 5);
323
366
  });