@torrent-tv/proxy 2.87.14 → 2.87.16

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.
@@ -6,14 +6,15 @@
6
6
  * measurements and physical limits: source bytes and download rate, encoded
7
7
  * media and encoder rate, segment sizes and client-link rate, the existing
8
8
  * browser buffer, its capacity, the proxy look-ahead, and the measured reserve
9
- * for interruptions. Rates are extrapolated from their measured linear trend;
10
- * there are no fitted weights or rate multipliers.
9
+ * for interruptions. A measured rate relaxes toward the lower of its latest
10
+ * value and its observed mean over the measurement span. Its integral remains
11
+ * unbounded for a positive measured rate; an old slope cannot invent a permanent
12
+ * cessation of service. There are no fitted weights or rate multipliers.
11
13
  */
12
14
 
13
15
  /**
14
- * The mean rate predicted over a horizon by the trend in all supplied
15
- * measurements. With no trend this is the measured rate. The regression treats
16
- * every measurement equally.
16
+ * The mean rate predicted over a horizon by the integral of the measured rate.
17
+ * Every supplied measurement contributes equally to the observed mean.
17
18
  *
18
19
  * @param {Array<{ at: number, value: number }>} readings
19
20
  * @param {number} now
@@ -30,48 +31,36 @@ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
30
31
  }
31
32
 
32
33
  /**
33
- * Constant-space measurements for one rate trend. Linear regression can be
34
- * evaluated from these sums without retaining every poll for the life of a
35
- * film.
34
+ * Measurements over the output's existing look-ahead horizon. The oldest
35
+ * boundary sample is retained until another sample replaces it, so a repeated
36
+ * report never changes the history or invents a new measurement.
36
37
  */
37
38
  export class RateTrend {
38
- #originAt = null;
39
- #lastAt = null;
40
- #count = 0;
41
- #sumTime = 0;
42
- #sumTimeSquared = 0;
43
- #sumValue = 0;
44
- #sumTimeValue = 0;
39
+ #readings = [];
40
+ #horizonMs;
41
+
42
+ constructor(horizonSeconds = Number.POSITIVE_INFINITY) {
43
+ this.#horizonMs = Math.max(0, horizonSeconds) * 1000;
44
+ }
45
45
 
46
46
  add(at, value) {
47
47
  if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 ||
48
- (this.#lastAt !== null && at <= this.#lastAt)) {
48
+ at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
49
49
  return false;
50
50
  }
51
- this.#originAt ??= at;
52
- const elapsed = (at - this.#originAt) / 1000;
53
- this.#count += 1;
54
- this.#sumTime += elapsed;
55
- this.#sumTimeSquared += elapsed ** 2;
56
- this.#sumValue += value;
57
- this.#sumTimeValue += elapsed * value;
58
- this.#lastAt = at;
51
+ this.#readings.push({ at, value });
52
+ const cutoff = at - this.#horizonMs;
53
+ while (this.#readings.length > 1 && this.#readings[1].at <= cutoff) {
54
+ this.#readings.shift();
55
+ }
59
56
  return true;
60
57
  }
61
58
 
62
59
  snapshot() {
63
- if (this.#count === 0) {
60
+ if (this.#readings.length === 0) {
64
61
  return null;
65
62
  }
66
- return {
67
- count: this.#count,
68
- originAt: this.#originAt,
69
- lastAt: this.#lastAt,
70
- sumTime: this.#sumTime,
71
- sumTimeSquared: this.#sumTimeSquared,
72
- sumValue: this.#sumValue,
73
- sumTimeValue: this.#sumTimeValue
74
- };
63
+ return summarizeRate(this.#readings);
75
64
  }
76
65
  }
77
66
 
@@ -96,14 +85,24 @@ export class RateTrend {
96
85
  */
97
86
  export function predictPlaybackReadiness(input = {}) {
98
87
  const now = Number.isFinite(input.now) ? input.now : Date.now();
99
- const position = finiteNonNegative(input.positionSeconds);
88
+ const tracks = Array.isArray(input.tracks) ? input.tracks : [];
89
+ const mediaOrigin = Math.max(0, ...tracks.map((track) =>
90
+ track.segments?.find(({ index }) => index === 0)?.mediaRanges?.[0]?.start ?? 0));
91
+ const position = Math.max(mediaOrigin, finiteNonNegative(input.positionSeconds));
100
92
  const duration = finiteNonNegative(input.durationSeconds);
101
93
  const remaining = Math.max(0, duration - position);
102
- const buffered = finiteNonNegative(input.bufferedAheadSeconds);
103
- const capacity = finiteNonNegative(input.bufferLimitSeconds);
94
+ const measuredRanges = tracks.map((track) => Array.isArray(track.clientRanges) ?
95
+ normalizedRanges(track.clientRanges) : null);
96
+ const buffered = measuredRanges.length > 0 && measuredRanges.every(Boolean) ?
97
+ Math.max(0, Math.min(...measuredRanges.map((ranges) => contiguousEnd(ranges, position) - position))) :
98
+ finiteNonNegative(input.bufferedAheadSeconds);
99
+ // A loader target can be lowered after a fragment retry. Bytes already held
100
+ // prove a larger capacity, even when an older page reports only that target.
101
+ const capacity = Math.max(buffered, finiteNonNegative(input.bufferLimitSeconds),
102
+ ...measuredRanges.map((ranges) => ranges ? ranges.reduce((total, range) =>
103
+ total + Math.max(0, range.end - Math.max(position, range.start)), 0) : 0));
104
104
  const reserve = Math.min(remaining, finiteNonNegative(input.reserveSeconds));
105
105
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
106
- const tracks = Array.isArray(input.tracks) ? input.tracks : [];
107
106
  const sources = new Map((Array.isArray(input.sources) ? input.sources : [])
108
107
  .filter((source) => typeof source?.id === "string")
109
108
  .map((source) => [source.id, source]));
@@ -123,6 +122,8 @@ export function predictPlaybackReadiness(input = {}) {
123
122
 
124
123
  const trackState = tracks.map((track) => ({
125
124
  track,
125
+ clientRanges: Array.isArray(track.clientRanges) ? normalizedRanges(track.clientRanges) :
126
+ [{ start: position, end: position + buffered }],
126
127
  ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
127
128
  curve: rateCurve(track.readings, now),
128
129
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
@@ -166,11 +167,13 @@ export function predictPlaybackReadiness(input = {}) {
166
167
  const { track, segments } = state;
167
168
  const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
168
169
  for (const segment of segments) {
169
- if (segment.endSeconds <= position + buffered) {
170
+ if (coveredSeconds(state.clientRanges, Math.max(position, segment.startSeconds), segment.endSeconds) >=
171
+ segment.endSeconds - Math.max(position, segment.startSeconds) - Number.EPSILON * duration) {
170
172
  continue;
171
173
  }
172
174
  const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
173
175
  const segmentSeconds = segment.endSeconds - segment.startSeconds;
176
+ // HLS fetches the complete file when any required part is absent.
174
177
  const sizeBits = exactBytes > 0
175
178
  ? exactBytes * 8
176
179
  : measuredBitsPerMediaSecond * segmentSeconds;
@@ -179,9 +182,9 @@ export function predictPlaybackReadiness(input = {}) {
179
182
  }
180
183
 
181
184
  const produced = state.ready.has(segment.index);
182
- const processed = Number(track.processedSeconds);
183
- const workStart = Math.max(position, segment.startSeconds,
184
- Number.isFinite(processed) ? processed : segment.startSeconds);
185
+ // Global encode progress may belong to another run beyond a missing
186
+ // segment. Only publication proves this segment's work is complete.
187
+ const workStart = Math.max(position, segment.startSeconds);
185
188
  const encodeWork = produced ? 0 : Math.max(0, segment.endSeconds - workStart);
186
189
  if (!produced && !(encodeWork > 0)) {
187
190
  unknownReason ??= "segment-production-delay-unavailable";
@@ -237,32 +240,6 @@ export function predictPlaybackReadiness(input = {}) {
237
240
  }
238
241
  }
239
242
 
240
- const rateRecoveryDelays = [];
241
- const sourceDemandByService = new Map();
242
- for (const sourceId of new Set(jobs.flatMap(({ sourceIntervals }) => [...sourceIntervals.keys()]))) {
243
- const source = sourceState.get(sourceId);
244
- const demand = sourceDemandByService.get(source.serviceId) ?? new Map();
245
- demand.set(sourceId, Number(source.source.bytesPerMediaSecond));
246
- sourceDemandByService.set(source.serviceId, demand);
247
- }
248
- for (const [serviceId, demandBySource] of sourceDemandByService) {
249
- const demand = [...demandBySource.values()].reduce((sum, value) => sum + value, 0);
250
- rateRecoveryDelays.push(downloadServices.get(serviceId).curve.timeAtLeast(demand));
251
- }
252
- for (const { track, curve } of trackState) {
253
- if (jobs.some((job) => job.track === track && !job.produced)) {
254
- rateRecoveryDelays.push(curve.timeAtLeast(1));
255
- }
256
- }
257
- const linkDemand = trackState.reduce((sum, { track, bitsPerMediaSecond }) =>
258
- sum + (jobs.some((job) => job.track === track) ? bitsPerMediaSecond : 0), 0);
259
- if (linkDemand > 0) {
260
- rateRecoveryDelays.push(link.timeAtLeast(linkDemand));
261
- }
262
- const rateRecoveryDelay = rateRecoveryDelays
263
- .filter(Number.isFinite)
264
- .reduce((latest, delay) => Math.max(latest, delay), 0);
265
-
266
243
  jobs.sort((left, right) => left.segment.startSeconds - right.segment.startSeconds ||
267
244
  left.segment.endSeconds - right.segment.endSeconds || left.trackIndex - right.trackIndex);
268
245
 
@@ -353,6 +330,7 @@ export function predictPlaybackReadiness(input = {}) {
353
330
  return { safe: safety.safe,
354
331
  bufferedAtStart: safety.bufferedAtStart,
355
332
  neededSeconds: safety.neededSeconds,
333
+ completionTimes: usefulCompletions.map(({ at }) => at),
356
334
  finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
357
335
  };
358
336
 
@@ -366,7 +344,7 @@ export function predictPlaybackReadiness(input = {}) {
366
344
  if (immediate.safe) {
367
345
  return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
368
346
  }
369
- const searchLimit = Math.max(maximumUsefulDelay, rateRecoveryDelay);
347
+ const searchLimit = maximumUsefulDelay;
370
348
  if (!(searchLimit > 0)) {
371
349
  return result(false, null, buffered, reserve, null, "no-safe-start-found", preparedSegments);
372
350
  }
@@ -376,7 +354,7 @@ export function predictPlaybackReadiness(input = {}) {
376
354
  const breakpoints = [...new Set([
377
355
  maximumUsefulDelay,
378
356
  searchLimit,
379
- ...rateRecoveryDelays.filter(Number.isFinite)
357
+ ...prefillSchedule.completionTimes
380
358
  ])]
381
359
  .filter((delay) => delay > 0 && delay <= searchLimit)
382
360
  .sort((left, right) => left - right);
@@ -412,17 +390,9 @@ export function predictPlaybackReadiness(input = {}) {
412
390
 
413
391
  function rateCurve(readings, now) {
414
392
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
415
- Number.isFinite(readings.originAt) && Number.isFinite(readings.lastAt) &&
416
- Number.isFinite(readings.sumTime) && Number.isFinite(readings.sumTimeSquared) &&
417
- Number.isFinite(readings.sumValue) && Number.isFinite(readings.sumTimeValue)) {
418
- const { count, originAt, sumTime, sumTimeSquared, sumValue, sumTimeValue } = readings;
419
- const meanAt = sumTime / count;
420
- const meanValue = sumValue / count;
421
- const denominator = sumTimeSquared - count * meanAt ** 2;
422
- const numerator = sumTimeValue - count * meanAt * meanValue;
423
- const slope = denominator > 0 ? numerator / denominator : 0;
424
- const current = Math.max(0, meanValue + slope * ((now - originAt) / 1000 - meanAt));
425
- return rateCurveFrom(current, slope);
393
+ Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
394
+ Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
395
+ return rateCurveFrom(readings, now);
426
396
  }
427
397
  const ordered = (Array.isArray(readings) ? readings : [])
428
398
  .filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
@@ -433,71 +403,70 @@ function rateCurve(readings, now) {
433
403
  return null;
434
404
  }
435
405
 
436
- const points = ordered.map((reading) => ({
437
- at: (reading.at - now) / 1000,
438
- value: reading.value
439
- }));
440
- const meanAt = points.reduce((sum, point) => sum + point.at, 0) / points.length;
441
- const meanValue = points.reduce((sum, point) => sum + point.value, 0) / points.length;
442
- const numerator = points.reduce((sum, point) =>
443
- sum + (point.at - meanAt) * (point.value - meanValue), 0);
444
- const denominator = points.reduce((sum, point) =>
445
- sum + (point.at - meanAt) ** 2, 0);
446
- const slope = denominator > 0 ? numerator / denominator : 0;
447
- const current = Math.max(0, meanValue - slope * meanAt);
448
- return rateCurveFrom(current, slope);
406
+ return rateCurveFrom(summarizeRate(ordered), now);
449
407
  }
450
408
 
451
- function rateCurveFrom(current, slope) {
452
- const zeroAt = slope < 0 ? -current / slope : Number.POSITIVE_INFINITY;
453
-
454
- const workBy = (seconds) => {
455
- const span = Math.min(Math.max(0, seconds), zeroAt);
456
- return Math.max(0, current * span + (slope * span ** 2) / 2);
457
- };
458
- const timeFor = (work) => {
459
- if (!(work > 0)) {
460
- return 0;
461
- }
462
- if (!(current > 0) && !(slope > 0)) {
463
- return Number.POSITIVE_INFINITY;
464
- }
465
- const discriminant = current ** 2 + 2 * slope * work;
466
- if (discriminant < 0) {
467
- return Number.POSITIVE_INFINITY;
468
- }
469
- if (slope < 0 && work > workBy(zeroAt)) {
470
- return Number.POSITIVE_INFINITY;
471
- }
472
- if (slope === 0) {
473
- return current > 0 ? work / current : Number.POSITIVE_INFINITY;
474
- }
475
- const root = Math.sqrt(discriminant);
476
- const denominator = current + root;
477
- return denominator > 0 ? (2 * work) / denominator : Number.POSITIVE_INFINITY;
409
+ function summarizeRate(readings) {
410
+ const latest = readings.at(-1);
411
+ return {
412
+ count: readings.length,
413
+ lastAt: latest.at,
414
+ lastValue: latest.value,
415
+ meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
416
+ spanSeconds: (latest.at - readings[0].at) / 1000
478
417
  };
418
+ }
419
+
420
+ function rateCurveFrom({ lastAt, lastValue, meanValue, spanSeconds }, now) {
421
+ const baseline = Math.max(0, Math.min(lastValue, meanValue));
422
+ const span = Math.max(Number.EPSILON, spanSeconds);
423
+ const age = Math.max(0, (now - lastAt) / 1000);
424
+ const excess = Math.max(0, lastValue - baseline) * Math.exp(-age / span);
425
+ // r(t) = baseline + excess * exp(-t/span).
426
+ // W(t) = integral(0..t, r(u)du). Positive measured service has no finite
427
+ // total-work ceiling merely because an old sample was faster.
428
+ const workBy = (seconds) => rateIntegral(Math.max(0, seconds), baseline, excess, span);
479
429
  return {
480
- timeAtLeast: (target) => {
481
- if (!Number.isFinite(target) || target < 0) {
482
- return Number.POSITIVE_INFINITY;
483
- }
484
- if (current >= target) {
485
- return 0;
486
- }
487
- return slope > 0 ? (target - current) / slope : Number.POSITIVE_INFINITY;
488
- },
489
- rateAt: (seconds) => Math.max(0, current + slope * Math.min(Math.max(0, seconds), zeroAt)),
430
+ rateAt: (seconds) => baseline + excess * Math.exp(-Math.max(0, seconds) / span),
490
431
  workBy,
491
432
  finish: (work, startAt) => {
492
- if (!(work > 0)) {
493
- return Math.max(0, startAt);
494
- }
495
433
  const start = Math.max(0, startAt);
496
- return start + (timeFor(work + workBy(start)) - start);
434
+ return start + invertRateIntegral(Math.max(0, work), baseline,
435
+ excess * Math.exp(-start / span), span);
497
436
  }
498
437
  };
499
438
  }
500
439
 
440
+ function rateIntegral(seconds, baseline, excess, span) {
441
+ return baseline * seconds - excess * span * Math.expm1(-seconds / span);
442
+ }
443
+
444
+ /** Invert the strictly increasing service integral, with no playback rule. */
445
+ function invertRateIntegral(work, baseline, excess, span) {
446
+ if (work === 0) {
447
+ return 0;
448
+ }
449
+ if (!(baseline > 0) || !Number.isFinite(work)) {
450
+ return Number.POSITIVE_INFINITY;
451
+ }
452
+ let low = 0;
453
+ let high = work / baseline;
454
+ if (excess === 0) {
455
+ return high;
456
+ }
457
+ while (true) {
458
+ const middle = (low + high) / 2;
459
+ if (middle === low || middle === high) {
460
+ return high;
461
+ }
462
+ if (rateIntegral(middle, baseline, excess, span) >= work) {
463
+ high = middle;
464
+ } else {
465
+ low = middle;
466
+ }
467
+ }
468
+ }
469
+
501
470
  function averageBitsPerMediaSecond(track, segments, ready) {
502
471
  let bits = 0;
503
472
  let mediaSeconds = 0;
@@ -522,8 +491,8 @@ function isSafeSchedule(completions, productions, trackState, position, duration
522
491
  startDelay) {
523
492
  // Client continuity is constrained by its buffer limit; source-stall reserve
524
493
  // 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 }]);
494
+ const clientRanges = trackState.map((state) => state.clientRanges.map((range) => ({ ...range })));
495
+ const preparedRanges = trackState.map((state) => state.clientRanges.map((range) => ({ ...range })));
527
496
  const orderedCompletions = [...completions]
528
497
  .filter(({ at }) => Number.isFinite(at))
529
498
  .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
@@ -545,13 +514,17 @@ function isSafeSchedule(completions, productions, trackState, position, duration
545
514
  const addThrough = (time) => {
546
515
  while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
547
516
  const { trackIndex, segment } = orderedProductions[productionIndex];
548
- addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
517
+ for (const range of segment.mediaRanges ?? [{ start: segment.startSeconds, end: segment.endSeconds }]) {
518
+ addRange(preparedRanges[trackIndex], range.start, range.end);
519
+ }
549
520
  productionIndex += 1;
550
521
  }
551
522
  while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
552
523
  const { trackIndex, segment } = orderedCompletions[completionIndex];
553
- addRange(clientRanges[trackIndex], segment.startSeconds, segment.endSeconds);
554
- addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
524
+ for (const range of segment.mediaRanges ?? [{ start: segment.startSeconds, end: segment.endSeconds }]) {
525
+ addRange(clientRanges[trackIndex], range.start, range.end);
526
+ addRange(preparedRanges[trackIndex], range.start, range.end);
527
+ }
555
528
  completionIndex += 1;
556
529
  }
557
530
  };
@@ -560,13 +533,18 @@ function isSafeSchedule(completions, productions, trackState, position, duration
560
533
  if (playerPosition >= duration) {
561
534
  return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
562
535
  }
563
- const available = Math.min(capacity, ...clientRanges.map((trackRanges) =>
564
- Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
536
+ const trackEnd = (index) => {
537
+ const last = trackState[index].segments.at(-1);
538
+ return last?.mediaRanges?.at(-1)?.end ?? duration;
539
+ };
540
+ const available = Math.min(capacity, ...clientRanges.map((trackRanges, index) =>
541
+ playerPosition >= trackEnd(index) ? Number.POSITIVE_INFINITY :
542
+ Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
565
543
  let neededSeconds = 0;
566
544
  let stockSafe = true;
567
545
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
568
546
  const required = time <= reserveUntil[trackIndex]
569
- ? Math.min(reserve, Math.max(0, duration - playerPosition))
547
+ ? Math.min(reserve, Math.max(0, trackEnd(trackIndex) - playerPosition))
570
548
  : 0;
571
549
  const preparedAhead = Math.max(0,
572
550
  contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
@@ -590,6 +568,9 @@ function isSafeSchedule(completions, productions, trackState, position, duration
590
568
 
591
569
  const playbackEnd = startDelay + Math.max(0, duration - position);
592
570
  const playbackBoundaries = [position + buffered,
571
+ ...trackState.flatMap(({ clientRanges }) => clientRanges.flatMap(({ start, end }) => [start, end])),
572
+ ...trackState.flatMap(({ segments }) => segments.flatMap((segment) =>
573
+ (segment.mediaRanges ?? []).flatMap(({ start, end }) => [start, end]))),
593
574
  ...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
594
575
  .filter((boundary) => boundary > position && boundary < duration)
595
576
  .map((boundary) => startDelay + boundary - position);
@@ -641,6 +622,21 @@ function addRange(ranges, start, end) {
641
622
  ranges.splice(0, ranges.length, ...merged);
642
623
  }
643
624
 
625
+ function normalizedRanges(ranges) {
626
+ const result = [];
627
+ for (const range of ranges) {
628
+ if (Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start) {
629
+ addRange(result, range.start, range.end);
630
+ }
631
+ }
632
+ return result;
633
+ }
634
+
635
+ function coveredSeconds(ranges, start, end) {
636
+ return ranges.reduce((total, range) => total + Math.max(0,
637
+ Math.min(end, range.end) - Math.max(start, range.start)), 0);
638
+ }
639
+
644
640
  function contiguousEnd(ranges, position) {
645
641
  for (const range of ranges) {
646
642
  if (range.start <= position && position <= range.end) {
@@ -300,6 +300,26 @@ export class PieceDiskStore {
300
300
  * @param {number} [at] - Offset within the piece to start at.
301
301
  * @returns {Promise<number>} Bytes read.
302
302
  */
303
+ hold(index) {
304
+ if (!this.#stored.has(index)) {
305
+ return null;
306
+ }
307
+ this.#reading.set(index, (this.#reading.get(index) ?? 0) + 1);
308
+ let held = true;
309
+ return () => {
310
+ if (!held) {
311
+ return;
312
+ }
313
+ held = false;
314
+ const outstanding = (this.#reading.get(index) ?? 1) - 1;
315
+ if (outstanding > 0) {
316
+ this.#reading.set(index, outstanding);
317
+ } else {
318
+ this.#reading.delete(index);
319
+ }
320
+ };
321
+ }
322
+
303
323
  async read(index, target, at = 0) {
304
324
  if (!this.#stored.has(index)) {
305
325
  throw new Error(`Piece ${index} is not on disk.`);
@@ -338,6 +358,9 @@ export class PieceDiskStore {
338
358
  * @returns {void}
339
359
  */
340
360
  forget(index) {
361
+ if (this.#reading.has(index)) {
362
+ return;
363
+ }
341
364
  const length = this.#stored.get(index);
342
365
  if (length === undefined) {
343
366
  return;
@@ -1407,8 +1407,12 @@ export class SharedPieceStore {
1407
1407
  }
1408
1408
  }
1409
1409
 
1410
- const release = await this.#claimSlot();
1410
+ // Hold the disk bytes before waiting for memory: another reader may move
1411
+ // the eviction boundary while this read is waiting for a slot.
1412
+ const releaseDisk = this.#disk.hold?.(index) ?? (() => {});
1413
+ let release = null;
1411
1414
  try {
1415
+ release = await this.#claimSlot();
1412
1416
  // Another caller may have brought it back while this one waited for a
1413
1417
  // slot. Registering a second buffer for the same piece would leave
1414
1418
  // whoever holds the first reading memory nothing evicts.
@@ -1434,7 +1438,8 @@ export class SharedPieceStore {
1434
1438
  this.#noteRevival(index);
1435
1439
  return target;
1436
1440
  } finally {
1437
- release();
1441
+ release?.();
1442
+ releaseDisk();
1438
1443
  }
1439
1444
  }
1440
1445
 
@@ -83,6 +83,7 @@ export class SegmentStore {
83
83
 
84
84
  /** Output key → what its directory holds. @type {Map<string, HeldContents>} */
85
85
  #held = new Map();
86
+ #mediaRanges = new Map();
86
87
 
87
88
  /** Output key → how to read its file names. @type {Map<string, object>} */
88
89
  #formats = new Map();
@@ -431,6 +432,17 @@ export class SegmentStore {
431
432
  return null;
432
433
  }
433
434
  const dir = path.join(this.#root, directoryNameFor(key));
435
+ const index = format?.segmentIndexFromName?.(served) ?? -1;
436
+ let mediaRanges = null;
437
+ if (format?.readMediaRanges && index >= 0) {
438
+ try {
439
+ mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
440
+ if (mediaRanges.length === 0) throw new Error("The closed file contains no playable media intervals.");
441
+ } catch (error) {
442
+ this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
443
+ return null;
444
+ }
445
+ }
434
446
  try {
435
447
  renameSync(path.join(dir, makingName), path.join(dir, served));
436
448
  } catch (error) {
@@ -445,8 +457,12 @@ export class SegmentStore {
445
457
  }
446
458
  // What the directory holds has changed, so the memory of it is stale.
447
459
  this.#held.delete(key);
448
- const index = format?.segmentIndexFromName?.(served) ?? -1;
449
460
  if (index >= 0) {
461
+ if (mediaRanges) {
462
+ const byIndex = this.#mediaRanges.get(key) ?? new Map();
463
+ byIndex.set(index, mediaRanges);
464
+ this.#mediaRanges.set(key, byIndex);
465
+ }
450
466
  this.announce(key, index);
451
467
  for (const listener of this.#publishedListeners) {
452
468
  listener(key, index);
@@ -455,6 +471,24 @@ export class SegmentStore {
455
471
  return served;
456
472
  }
457
473
 
474
+ mediaRangesOf(key, index) {
475
+ const known = this.#mediaRanges.get(key)?.get(index);
476
+ if (known) return known;
477
+ const format = this.#formats.get(key);
478
+ if (!format?.readMediaRanges) return undefined;
479
+ const filePath = this.pathOf(key, index);
480
+ if (!filePath) return undefined;
481
+ try {
482
+ const ranges = format.readMediaRanges(readFileSync(filePath));
483
+ const byIndex = this.#mediaRanges.get(key) ?? new Map();
484
+ byIndex.set(index, ranges);
485
+ this.#mediaRanges.set(key, byIndex);
486
+ return ranges;
487
+ } catch {
488
+ return [];
489
+ }
490
+ }
491
+
458
492
  /**
459
493
  * The init segment kept for this output, or null until one was read whole.
460
494
  *
@@ -842,6 +876,7 @@ export class SegmentStore {
842
876
  }
843
877
  this.#held.delete(key);
844
878
  this.#formats.delete(key);
879
+ this.#mediaRanges.delete(key);
845
880
  this.#touched.delete(key);
846
881
  this.#inits.delete(key);
847
882
  this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
@@ -391,6 +391,7 @@ export class Viewer {
391
391
  linkSampleAt = null,
392
392
  linkSampleAgeMs = null,
393
393
  bufferedAheadSec,
394
+ bufferedRanges,
394
395
  bufferLimitSeconds,
395
396
  positionSeconds = null,
396
397
  playing,
@@ -434,6 +435,11 @@ export class Viewer {
434
435
  : now
435
436
  : this.netReport?.linkSampleMeasuredAt ?? null,
436
437
  bufferedAheadSec: held,
438
+ bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
439
+ Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
440
+ .map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
441
+ Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.start >= 0 && range.end > range.start)
442
+ .map(({ start, end }) => ({ start, end }))])) : null,
437
443
  bufferLimitSeconds: Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
438
444
  ? bufferLimitSeconds
439
445
  : this.netReport?.bufferLimitSeconds ?? null,
@@ -44,7 +44,7 @@ class FakeProcess extends EventEmitter {
44
44
  this.signals.push(signal);
45
45
  // A real ffmpeg dies a moment later; the test wants the bookkeeping to
46
46
  // happen where it can be seen, so the exit is immediate and synchronous.
47
- this.emit("exit", null, signal);
47
+ this.emit("close", null, signal);
48
48
  }
49
49
  }
50
50
 
@@ -172,7 +172,7 @@ test("how many encoders are running is answered by the one thing that makes them
172
172
  assert.equal(made.runningCount(), 2, "two processes on one output are two encoders, not one");
173
173
 
174
174
  // And a run that has ended is forgotten as it ends, not at the next pass.
175
- processes.get(first).emit("exit", 0, null);
175
+ processes.get(first).emit("close", 0, null);
176
176
  assert.equal(made.runningCount(), 1);
177
177
 
178
178
  // A SUSPENDED run is stopped where it stands and is using nothing, so a cost
@@ -372,7 +372,7 @@ test("every ending is counted, and our own kill is not counted as normal", () =>
372
372
  wants(made, [{ from: 100, to: 130 }]);
373
373
  made.reconcile();
374
374
  const run = made.runsOn(PICTURE)[0];
375
- processes.get(run).emit("exit", 255, null);
375
+ processes.get(run).emit("close", 255, null);
376
376
  wants(made, []);
377
377
  made.reconcile();
378
378
  const tally = made.endings();
@@ -59,7 +59,7 @@ class FakeProcess extends EventEmitter {
59
59
  }
60
60
 
61
61
  kill(signal) {
62
- this.emit("exit", null, signal);
62
+ this.emit("close", null, signal);
63
63
  }
64
64
  }
65
65
 
@@ -30,6 +30,7 @@ class FakeProcess extends EventEmitter {
30
30
 
31
31
  exitWith(code, signal = null) {
32
32
  this.emit("exit", code, signal);
33
+ this.emit("close", code, signal);
33
34
  }
34
35
  }
35
36