@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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 2.87.16
2
+
3
+ - **Fix**: Hold spilled source bytes across memory reservation so concurrent eviction cannot terminate ffmpeg input with a missing disk piece.
4
+ - **Fix**: Publish a run's final closed segment only after complete input termination, retain earlier completed cuts, and drain output notifications before classifying an exit.
5
+ - **Fix**: Preserve copied media presentation times when replacing per-segment headers with one shared init, including backward seeks after opening at a later position.
6
+ - **Fix**: Feed measured segment intervals and separate browser track ranges into the service integral; retain media beyond holes, avoid duplicate transfer estimates, and price missing segments independently of another run's progress.
7
+
8
+ ## 2.87.15
9
+
10
+ - **Fix**: Integrate a bounded, nonnegative forecast of recent measured service rates, so an old faster link sample cannot predict permanent delivery failure and prevent playback of fully prepared media.
11
+ - **Fix**: Treat the already-held browser buffer as a measured lower bound on capacity when its loader target has been reduced by fragment retries.
12
+ - **Chore**: Record forecast measurements when the playback-readiness reason changes.
13
+
1
14
  ## 2.87.14
2
15
 
3
16
  - **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
@@ -101,6 +101,15 @@ remaining-work estimates and share the measured download rate of their torrent.
101
101
  The browser reports buffer state and delivery measurements, but does not
102
102
  calculate or override readiness.
103
103
 
104
+ Service work is the integral of a nonnegative measured-rate forecast. The latest
105
+ rate relaxes over the observed measurement span toward the lower of its latest
106
+ value and the recent mean; the measurement history covers the output look-ahead.
107
+ A positive measured rate cannot become permanently zero from an old declining
108
+ linear fit. A measured zero remains zero until a new measurement arrives.
109
+ The reported loader limit is bounded below by the browser buffer already held.
110
+ During startup waiting the browser refreshes stale delivery measurements through
111
+ the existing link probe and reports their age to avoid clock-offset errors.
112
+
104
113
  ## Subtitles
105
114
 
106
115
  `GET /api/subtitles` requires `sourceKey` and `fileIndex`. Without `trackIndex`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.14",
3
+ "version": "2.87.16",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -109,6 +109,7 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
109
109
  linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
110
110
  linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
111
111
  bufferedAheadSec,
112
+ bufferedRanges: body.bufferedRanges,
112
113
  bufferLimitSeconds:
113
114
  Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0 ? bufferLimitSeconds : undefined,
114
115
  consumerId,
@@ -103,6 +103,10 @@ export class EncodeRun {
103
103
 
104
104
  /** Half a name left over from the last chunk of the encoder's own channel. */
105
105
  #closedTail = "";
106
+ #pendingClosed = null;
107
+ #inputTruncated = false;
108
+ #stderrReadTail = "";
109
+ #processExited = false;
106
110
 
107
111
  /**
108
112
  * The last piece named on the ready channel while the run was still running
@@ -242,7 +246,7 @@ export class EncodeRun {
242
246
  this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
243
247
  // Told the NAME of every piece the encoder has closed. The name is the
244
248
  // proof it is whole; nothing else here can prove that.
245
- this.onClosed = typeof onClosed === "function" ? onClosed : () => {};
249
+ this.onClosed = typeof onClosed === "function" ? onClosed : (name) => name;
246
250
  this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
247
251
  this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
248
252
  this.argsDescribed = argsDescribed;
@@ -420,6 +424,9 @@ export class EncodeRun {
420
424
  const line = String(chunk).trim();
421
425
  if (line.length > 0) {
422
426
  this.lastError = line;
427
+ const errorText = this.#stderrReadTail + String(chunk);
428
+ this.#inputTruncated ||= /Stream ends prematurely|Input\/output error|Error during demuxing/i.test(errorText);
429
+ this.#stderrReadTail = errorText.slice(-512);
423
430
  this.logger.warn(`ffmpeg #${this.from}..#${this.to} of ${this.address}: ${line}`);
424
431
  }
425
432
  });
@@ -428,7 +435,9 @@ export class EncodeRun {
428
435
  this.lastError = message;
429
436
  this.#finish(ENCODE_EXIT.FAILED, `the process could not be started: ${message}`, null, null);
430
437
  });
431
- process.on("exit", (code, signal) => this.#onExit(code, signal));
438
+ // `close` follows drained stdio; `exit` can precede the final filename.
439
+ process.on("exit", () => { this.#processExited = true; });
440
+ process.on("close", (code, signal) => this.#onExit(code, signal));
432
441
  }
433
442
 
434
443
  /**
@@ -504,11 +513,23 @@ export class EncodeRun {
504
513
  if (name.length === 0) {
505
514
  continue;
506
515
  }
516
+ if (this.#stopping) continue;
517
+ // A subsequent closed file proves the previous one reached a cut.
518
+ // The last file can instead have been flushed by an input failure.
519
+ if (this.#pendingClosed !== null) {
520
+ this.#publishClosed(this.#pendingClosed);
521
+ }
522
+ this.#pendingClosed = name;
523
+ }
524
+ }
525
+
526
+ #publishClosed(name) {
507
527
  // ITS SERVED NAME, which is what whoever owns the disk gives it in answer.
508
528
  // ffmpeg writes a piece under a working name and reports that; the piece
509
529
  // becomes servable by being renamed, and everything below works in the
510
530
  // name a request can actually ask for.
511
- const served = this.onClosed(name) ?? name;
531
+ const served = this.onClosed(name);
532
+ if (!served) return false;
512
533
  if (!this.#stopping) {
513
534
  this.#provenName = served;
514
535
  }
@@ -524,7 +545,7 @@ export class EncodeRun {
524
545
  if (Number.isInteger(index)) {
525
546
  this.noteProduced(index);
526
547
  }
527
- }
548
+ return true;
528
549
  }
529
550
 
530
551
  noteSpeed(speedX) {
@@ -545,6 +566,12 @@ export class EncodeRun {
545
566
  if (this.#process === null || this.#ended) {
546
567
  return;
547
568
  }
569
+ // A cut closed before our stop stays complete; the file flushed by the
570
+ // subsequent signal must never be published as that same proof.
571
+ if (this.#pendingClosed !== null && !this.#processExited && !this.#inputTruncated) {
572
+ this.#publishClosed(this.#pendingClosed);
573
+ this.#pendingClosed = null;
574
+ }
548
575
  this.#stopping = true;
549
576
  this.#stopReason = because;
550
577
  this.#stopOrderedAt = this.now();
@@ -646,14 +673,24 @@ export class EncodeRun {
646
673
  // that exits cleanly at #11 has not finished, whatever the film's length;
647
674
  // and a run with no end has nothing but the film to be measured against.
648
675
  const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
676
+ const pendingIndex = this.#pendingClosed === null ? null :
677
+ this.indexOfName(this.#pendingClosed);
649
678
  const outcome = classifyEncodeExit({
650
679
  code,
651
- producedThrough: this.#produced.size > 0 ? this.reached : null,
652
- producedCount: this.#produced.size,
680
+ producedThrough: this.#inputTruncated ? this.reached :
681
+ (pendingIndex === this.head ? pendingIndex : this.reached),
682
+ producedCount: this.#produced.size + Number(pendingIndex === this.head && !this.#inputTruncated),
653
683
  lastSegmentIndex: endOfWork,
654
684
  inputUnavailable: this.inputUnavailable(this.lastError)
655
685
  });
656
686
  if (outcome === ENCODE_EXIT.COMPLETE) {
687
+ if (this.#pendingClosed !== null && !this.#inputTruncated) {
688
+ if (!this.#publishClosed(this.#pendingClosed)) {
689
+ this.#finish(ENCODE_EXIT.SHORT, "its final file could not be published", code, signal);
690
+ return;
691
+ }
692
+ this.#pendingClosed = null;
693
+ }
657
694
  this.#finish(ENCODE_EXIT.COMPLETE, "it reached the end of what it was given", code, signal);
658
695
  return;
659
696
  }
@@ -827,7 +827,8 @@ export class EncodeRuns {
827
827
  session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
828
828
  inputUnavailable: (message) => isInputUnavailable(message),
829
829
  onProgress: (report) => this.#noteRunProgress(session, run, report),
830
- indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
830
+ indexOfName: (name) => session.segmentFormat.segmentIndexFromName(
831
+ session.segmentFormat.servedNameOf?.(name) ?? name),
831
832
  // Why this encoder exists, recorded with its argument list. It used to be
832
833
  // handed to a separate `start` call; there is no separate call now.
833
834
  because,
@@ -11,6 +11,9 @@
11
11
  */
12
12
 
13
13
  import {
14
+ rebaseSegmentDecodeTimes,
15
+ neutralizeEmptyEdits,
16
+ readPresentationRanges,
14
17
  readSelfContainedStartSeconds,
15
18
  readTrackTimescales,
16
19
  readVideoSampleSize,
@@ -210,6 +213,7 @@ export const fmp4Format = {
210
213
  const { firstFragment } = findFragmentBounds(bytes);
211
214
  return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
212
215
  },
216
+ prepareSharedInit: neutralizeEmptyEdits,
213
217
 
214
218
  /**
215
219
  * A piece with its init header removed, and the trailing random-access index
@@ -275,6 +279,7 @@ export const fmp4Format = {
275
279
  * transcode itself; the box walk never descends into `mdat`.
276
280
  */
277
281
  needsSegmentRewrite: true,
282
+ readMediaRanges: readPresentationRanges,
278
283
 
279
284
  /**
280
285
  * Whether a segment carries every track the init promises.
@@ -425,13 +430,16 @@ export const fmp4Format = {
425
430
  return readVideoSampleSize(initBytes);
426
431
  },
427
432
 
428
- prepareSegmentBytes(bytes, { startSeconds, initBytes }) {
433
+ prepareSegmentBytes(bytes, { startSeconds, initBytes, rawBytes }) {
429
434
  if (!initBytes || initBytes.length === 0) {
430
435
  // No init cached yet — nothing to read timescales from. The player always
431
436
  // fetches `#EXT-X-MAP` before any segment, so this is not reachable in
432
437
  // practice; serve unmodified rather than guess a timescale.
433
438
  return bytes;
434
439
  }
435
- return stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
440
+ const ownInit = rawBytes ? this.extractInit(rawBytes) : null;
441
+ return ownInit
442
+ ? rebaseSegmentDecodeTimes(bytes, ownInit, initBytes)
443
+ : stampSegmentStartTime(bytes, startSeconds, readTrackTimescales(initBytes));
436
444
  }
437
445
  };
@@ -20,6 +20,7 @@ import { mpegtsFormat } from "./mpegts.js";
20
20
  * output timeline, from the session's segment-boundary table.
21
21
  * @property {Buffer | null} initBytes - The init segment being served for this
22
22
  * session, when the format has one (fMP4 needs it to read track timescales).
23
+ * @property {Buffer} [rawBytes] - Self-contained bytes before removing their init.
23
24
  */
24
25
 
25
26
  /**
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * @type {ReadonlySet<string>}
17
17
  */
18
- const CONTAINER_BOXES = new Set(["moov", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
18
+ const CONTAINER_BOXES = new Set(["moov", "mvex", "trak", "mdia", "minf", "stbl", "edts", "moof", "traf"]);
19
19
 
20
20
  /**
21
21
  * Walk the box tree, invoking `visit` for every box encountered.
@@ -95,6 +95,142 @@ export function readTrackTimescales(initSegment) {
95
95
  return timescales;
96
96
  }
97
97
 
98
+ /** Movie presentation time minus track media time, in seconds per track. */
99
+ export function readTrackEditOffsets(initSegment) {
100
+ const scales = readTrackTimescales(initSegment);
101
+ const offsets = new Map([...scales.keys()].map((id) => [id, 0]));
102
+ let movieScale = 0;
103
+ let trackId = null;
104
+ walkBoxes(initSegment, (type, start, end) => {
105
+ const version = initSegment[start];
106
+ if (type === "mvhd") {
107
+ const at = start + (version === 1 ? 20 : 12);
108
+ if (at + 4 <= end) movieScale = initSegment.readUInt32BE(at);
109
+ } else if (type === "tkhd") {
110
+ const at = start + (version === 1 ? 20 : 12);
111
+ if (at + 4 <= end) trackId = initSegment.readUInt32BE(at);
112
+ } else if (type === "elst" && scales.has(trackId) && movieScale > 0 && start + 8 <= end) {
113
+ const count = initSegment.readUInt32BE(start + 4);
114
+ const width = version === 1 ? 20 : 12;
115
+ let movieStart = 0;
116
+ for (let entry = 0, at = start + 8; entry < count && at + width <= end; entry++, at += width) {
117
+ const duration = version === 1 ? Number(initSegment.readBigUInt64BE(at)) : initSegment.readUInt32BE(at);
118
+ const mediaTime = version === 1 ? Number(initSegment.readBigInt64BE(at + 8)) : initSegment.readInt32BE(at + 4);
119
+ if (mediaTime === -1) {
120
+ movieStart += duration / movieScale;
121
+ } else {
122
+ offsets.set(trackId, movieStart - mediaTime / scales.get(trackId));
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ });
128
+ return offsets;
129
+ }
130
+
131
+ /** An init shared across seeks has no segment-specific empty edit. */
132
+ export function neutralizeEmptyEdits(initSegment) {
133
+ const neutral = Buffer.from(initSegment);
134
+ walkBoxes(neutral, (type, start, end) => {
135
+ if (type !== "elst" || start + 8 > end) return;
136
+ const version = neutral[start];
137
+ const width = version === 1 ? 20 : 12;
138
+ const count = neutral.readUInt32BE(start + 4);
139
+ for (let entry = 0, at = start + 8; entry < count && at + width <= end; entry++, at += width) {
140
+ const mediaTime = version === 1 ? neutral.readBigInt64BE(at + 8) : BigInt(neutral.readInt32BE(at + 4));
141
+ if (mediaTime !== -1n) continue;
142
+ if (version === 1) neutral.writeBigUInt64BE(0n, at);
143
+ else neutral.writeUInt32BE(0, at);
144
+ }
145
+ });
146
+ return neutral;
147
+ }
148
+
149
+ /** Preserve sample presentation times when replacing a piece's own init. */
150
+ export function rebaseSegmentDecodeTimes(segment, ownInit, sessionInit) {
151
+ const scales = readTrackTimescales(sessionInit);
152
+ const ownOffsets = readTrackEditOffsets(ownInit);
153
+ const sessionOffsets = readTrackEditOffsets(sessionInit);
154
+ const stamped = Buffer.from(segment);
155
+ let trackId = null;
156
+ walkBoxes(stamped, (type, start, end) => {
157
+ if (type === "tfhd" && start + 8 <= end) {
158
+ trackId = stamped.readUInt32BE(start + 4);
159
+ } else if (type === "tfdt" && scales.has(trackId) && ownOffsets.has(trackId)) {
160
+ const version = stamped[start];
161
+ if (start + (version === 1 ? 12 : 8) > end) return;
162
+ const existing = version === 1 ? Number(stamped.readBigUInt64BE(start + 4)) : stamped.readUInt32BE(start + 4);
163
+ const value = existing + Math.round((ownOffsets.get(trackId) - sessionOffsets.get(trackId)) * scales.get(trackId));
164
+ if (value < 0) throw new Error("A fragment cannot be placed before its shared init's decode origin.");
165
+ if (version === 1) stamped.writeBigUInt64BE(BigInt(value), start + 4);
166
+ else if (value <= 0xffffffff) stamped.writeUInt32BE(value, start + 4);
167
+ else throw new Error("Fragment decode time exceeds its 32-bit field.");
168
+ }
169
+ });
170
+ return stamped;
171
+ }
172
+
173
+ /** Presentation intervals from the durations and composition times of samples. */
174
+ export function readPresentationRanges(raw) {
175
+ const scales = readTrackTimescales(raw);
176
+ const edits = readTrackEditOffsets(raw);
177
+ const byTrack = new Map();
178
+ const defaultDurations = new Map();
179
+ let trackId = null;
180
+ let decodeTime = 0;
181
+ let defaultDuration = 0;
182
+ walkBoxes(raw, (type, start, end) => {
183
+ if (type === "trex" && start + 20 <= end) {
184
+ defaultDurations.set(raw.readUInt32BE(start + 4), raw.readUInt32BE(start + 12));
185
+ } else if (type === "tfhd" && start + 8 <= end) {
186
+ const flags = raw.readUIntBE(start + 1, 3);
187
+ trackId = raw.readUInt32BE(start + 4);
188
+ const at = start + 8 + ((flags & 1) ? 8 : 0) + ((flags & 2) ? 4 : 0);
189
+ defaultDuration = (flags & 8) && at + 4 <= end ? raw.readUInt32BE(at) :
190
+ defaultDurations.get(trackId) ?? 0;
191
+ } else if (type === "tfdt" && start + (raw[start] === 1 ? 12 : 8) <= end) {
192
+ decodeTime = raw[start] === 1 ? Number(raw.readBigUInt64BE(start + 4)) : raw.readUInt32BE(start + 4);
193
+ } else if (type === "trun" && scales.has(trackId) && start + 8 <= end) {
194
+ const scale = scales.get(trackId);
195
+ const flags = raw.readUIntBE(start + 1, 3);
196
+ const count = raw.readUInt32BE(start + 4);
197
+ let at = start + 8 + ((flags & 1) ? 4 : 0) + ((flags & 4) ? 4 : 0);
198
+ const width = Number(Boolean(flags & 0x100)) * 4 + Number(Boolean(flags & 0x200)) * 4 +
199
+ Number(Boolean(flags & 0x400)) * 4 + Number(Boolean(flags & 0x800)) * 4;
200
+ const ranges = byTrack.get(trackId) ?? [];
201
+ for (let sample = 0; sample < count && at + width <= end; sample++) {
202
+ const duration = flags & 0x100 ? raw.readUInt32BE(at) : defaultDuration;
203
+ if (flags & 0x100) at += 4;
204
+ if (flags & 0x200) at += 4;
205
+ if (flags & 0x400) at += 4;
206
+ const composition = flags & 0x800 ?
207
+ (raw[start] === 1 ? raw.readInt32BE(at) : raw.readUInt32BE(at)) : 0;
208
+ if (flags & 0x800) at += 4;
209
+ if (!(duration > 0)) return;
210
+ const position = (decodeTime + composition) / scale + (edits.get(trackId) ?? 0);
211
+ ranges.push({ start: position, end: position + duration / scale });
212
+ decodeTime += duration;
213
+ }
214
+ byTrack.set(trackId, ranges);
215
+ }
216
+ });
217
+ // Merge in presentation order: B frames arrive in decode order.
218
+ const mergedTracks = [...byTrack.values()].map((samples) => {
219
+ const merged = [];
220
+ for (const sample of samples.sort((left, right) => left.start - right.start)) {
221
+ const previous = merged.at(-1);
222
+ if (previous && sample.start - previous.end <= Number.EPSILON * Math.max(1, sample.end) * 8) {
223
+ previous.end = Math.max(previous.end, sample.end);
224
+ } else merged.push({ ...sample });
225
+ }
226
+ return merged;
227
+ });
228
+ // A multiplexed segment is playable only where every declared track exists.
229
+ return mergedTracks.reduce((common, ranges) => common.flatMap((left) => ranges
230
+ .map((right) => ({ start: Math.max(left.start, right.start), end: Math.min(left.end, right.end) }))
231
+ .filter(({ start, end }) => end > start)), mergedTracks[0] ?? []);
232
+ }
233
+
98
234
  /**
99
235
  * Rewrite every fragment's `tfdt` so the segment declares that it starts at
100
236
  * `startSeconds` on the media timeline.
@@ -419,6 +419,7 @@ export class SegmentServing {
419
419
  }
420
420
  const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
421
421
  startSeconds: stampStart,
422
+ rawBytes: raw,
422
423
  initBytes: this.#host.segmentStore.initOf(session.outputKey ?? "")
423
424
  });
424
425
  this.#host.encodeRuns.noteRunProducedSegment(session, filePath);
@@ -541,6 +542,7 @@ export class SegmentServing {
541
542
  const trueStart = selfContained ? format.readSegmentStartSeconds?.(raw) ?? null : null;
542
543
  const prepared = format.prepareSegmentBytes(bytes, {
543
544
  startSeconds: trueStart ?? this.#host.outputTimes.publishedStartTime(like, index),
545
+ rawBytes: raw,
544
546
  initBytes: this.#host.segmentStore.initOf(goneKey)
545
547
  });
546
548
  logger.info(`[hold] ${fileName} served from the stored pieces of ${goneKey}, the output that first answered it`);
@@ -802,7 +804,9 @@ export class SegmentServing {
802
804
  if (!found) {
803
805
  continue;
804
806
  }
805
- const init = session.segmentFormat.extractInit(cached ?? await readFile(found));
807
+ const ownInit = session.segmentFormat.extractInit(cached ?? await readFile(found));
808
+ const init = ownInit && session.segmentFormat.prepareSharedInit ?
809
+ session.segmentFormat.prepareSharedInit(ownInit) : ownInit;
806
810
  if (!init || init.length === 0) {
807
811
  continue;
808
812
  }
@@ -568,7 +568,8 @@ export class ViewerRequests {
568
568
  const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => ({
569
569
  index,
570
570
  startSeconds: timeline.publishedStartOf(index),
571
- endSeconds: timeline.publishedStartOf(index + 1)
571
+ endSeconds: timeline.publishedStartOf(index + 1),
572
+ mediaRanges: this.#host.segmentStore.mediaRangesOf(output.outputKey, index)
572
573
  }));
573
574
  const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
574
575
  .filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
@@ -618,6 +619,8 @@ export class ViewerRequests {
618
619
  : await this.#host.quality.observedStreamMbps(output);
619
620
  tracks.push({
620
621
  id: output.outputKey,
622
+ clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
623
+ viewerReading?.bufferedRanges?.media,
621
624
  sourceIds,
622
625
  processedSeconds: trackProgress?.processedSeconds,
623
626
  bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 : 0,
@@ -663,7 +666,7 @@ export class ViewerRequests {
663
666
  return Number.isFinite(segmentSeconds) && segmentSeconds > 0 ? segmentSeconds : 0;
664
667
  }));
665
668
 
666
- return predictPlaybackReadiness({
669
+ const input = {
667
670
  now,
668
671
  positionSeconds,
669
672
  durationSeconds: session.file.durationSeconds,
@@ -675,7 +678,24 @@ export class ViewerRequests {
675
678
  sources: [...sourceMeasurements.values()],
676
679
  tracks,
677
680
  linkReadings: measurement.link.snapshot()
678
- });
681
+ };
682
+ const forecast = predictPlaybackReadiness(input);
683
+ if (measurement.forecastReason !== forecast.reason) {
684
+ measurement.forecastReason = forecast.reason;
685
+ logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
686
+ ...forecast,
687
+ positionSeconds,
688
+ durationSeconds: input.durationSeconds,
689
+ bufferLimitSeconds: input.bufferLimitSeconds,
690
+ lookaheadSeconds: input.lookaheadSeconds,
691
+ link: input.linkReadings,
692
+ sources: input.sources,
693
+ tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
694
+ id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
695
+ }))
696
+ })}`);
697
+ }
698
+ return forecast;
679
699
  }
680
700
 
681
701
  #rateStateFor(session, consumerId) {
@@ -687,7 +707,7 @@ export class ViewerRequests {
687
707
  const id = typeof consumerId === "string" ? consumerId : "";
688
708
  let state = byViewer.get(id);
689
709
  if (!state) {
690
- state = { link: new RateTrend(), downloads: new Map(), tracks: new Map() };
710
+ state = { link: new RateTrend(this.#host.lookaheadSeconds), downloads: new Map(), tracks: new Map() };
691
711
  byViewer.set(id, state);
692
712
  }
693
713
  return state;
@@ -696,7 +716,7 @@ export class ViewerRequests {
696
716
  #downloadRateReadings(state, serviceId, downloadSpeed, now) {
697
717
  let trend = state.downloads.get(serviceId);
698
718
  if (!trend) {
699
- trend = new RateTrend();
719
+ trend = new RateTrend(this.#host.lookaheadSeconds);
700
720
  state.downloads.set(serviceId, trend);
701
721
  }
702
722
  const speed = Number(downloadSpeed);
@@ -710,7 +730,7 @@ export class ViewerRequests {
710
730
  const id = output.outputKey;
711
731
  let trend = state.tracks.get(id);
712
732
  if (!trend) {
713
- trend = new RateTrend();
733
+ trend = new RateTrend(this.#host.lookaheadSeconds);
714
734
  state.tracks.set(id, trend);
715
735
  }
716
736
  const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;