@torrent-tv/proxy 2.107.52 → 2.107.54

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.
@@ -35,12 +35,6 @@ export function sourceFileIndexesForOutput(output) {
35
35
  * @param {Array<{ sourceId: string, ranges: object[] }> | undefined} right
36
36
  * @returns {boolean}
37
37
  */
38
- function sameInputs(left, right) {
39
- if (left === right) return true;
40
- if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
41
- return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
42
- }
43
-
44
38
  function browserServiceSummary(value) {
45
39
  if (!value || typeof value !== "object") return null;
46
40
  return Object.fromEntries(["init", "media"].filter(kind => value[kind]).map(kind => [kind, value[kind]]).map(([kind, service]) => [kind, {
@@ -93,6 +87,49 @@ function forecastInputIdentity(state) {
93
87
  return state && JSON.stringify([state.sourceId, state.trackId, state.segmentIndex, state.phase]);
94
88
  }
95
89
 
90
+ function unknownCapture(owner, actionId, generation, reason) {
91
+ return { owner, clock: "performance", clockEpoch: null, startedAt: null, completedAt: null,
92
+ actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null,
93
+ revision: null, validity: "unknown", unknownReason: reason };
94
+ }
95
+
96
+ function sourceCaptureOf(stats, actionId, generation) {
97
+ const source = stats?.sourceCapture;
98
+ if (!source || source.owner !== "torrent-worker" || source.clock !== "performance" ||
99
+ source.validity !== "valid" || !Number.isFinite(source.clockEpoch) ||
100
+ source.clockEpoch !== performance.timeOrigin ||
101
+ !Number.isFinite(source.startedAt) || !Number.isFinite(source.completedAt) ||
102
+ source.completedAt < source.startedAt || !Number.isSafeInteger(source.revision)) {
103
+ return { ...unknownCapture("torrent-worker", actionId, generation,
104
+ source?.unknownReason ?? "source-capture-unavailable"),
105
+ ...(Number.isFinite(source?.clockEpoch) ? { clockEpoch: source.clockEpoch } : {}),
106
+ ...(Number.isSafeInteger(source?.revision) ? { revision: source.revision } : {}) };
107
+ }
108
+ return { ...source, actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null };
109
+ }
110
+
111
+ function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
112
+ const runCaptures = (liveRuns ?? []).map(run => ({ owner: "proxy-encoder", clock: "performance",
113
+ clockEpoch: run.progress?.clockEpoch, startedAt: run.progress?.updatedAtMonotonic,
114
+ completedAt: run.progress?.updatedAtMonotonic, revision: run.progress?.updatedAtMonotonic,
115
+ validity: Number.isFinite(run.progress?.clockEpoch) && Number.isFinite(run.progress?.updatedAtMonotonic)
116
+ ? "valid" : "unknown", unknownReason: Number.isFinite(run.progress?.clockEpoch) &&
117
+ Number.isFinite(run.progress?.updatedAtMonotonic) ? null : "encoder-progress-capture-unavailable" }));
118
+ const captures = [...(Array.isArray(sampleCaptures) ? sampleCaptures : []), ...runCaptures];
119
+ const epoch = captures[0]?.clockEpoch;
120
+ const valid = captures.length > 0 && epoch === performance.timeOrigin && captures.every(capture =>
121
+ capture.owner === "proxy-encoder" && capture.clock === "performance" &&
122
+ Number.isFinite(capture.clockEpoch) && capture.clockEpoch === epoch && Number.isFinite(capture.startedAt) &&
123
+ Number.isFinite(capture.completedAt) && capture.completedAt >= capture.startedAt &&
124
+ Number.isFinite(capture.revision));
125
+ if (!valid) return unknownCapture("proxy-encoder", actionId, generation, "encoder-capture-unavailable");
126
+ return { owner: "proxy-encoder", clock: "performance", clockEpoch: epoch,
127
+ startedAt: Math.min(...captures.map(capture => capture.startedAt)),
128
+ completedAt: Math.max(...captures.map(capture => capture.completedAt)), actionId: actionId ?? null,
129
+ generation: Number.isSafeInteger(generation) ? generation : null,
130
+ revision: captures.at(-1).revision ?? null, samples: captures, validity: "valid", unknownReason: null };
131
+ }
132
+
96
133
  function observeForecastInput(observeWait, sourceKey, state, event) {
97
134
  if (!state) return;
98
135
  observeWait?.(sourceKey, "process", {
@@ -119,53 +156,43 @@ function segmentAddressOf(output, fileName) {
119
156
  export class ViewerRequests {
120
157
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
121
158
  #host;
122
- /** @type {WeakMap<object, Map<string, object>>} */
123
- #rateStateBySession = new WeakMap();
124
- /** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, observedWaits: Set<number> }>} */
125
- #waitObservations = new Map();
126
159
  /**
127
- * What the start forecasts cost, said once a minute: how many there were,
128
- * their time on the main thread, and for each track whether the times of its
129
- * pieces were kept from the last poll or why they were worked out again. In
130
- * r37 the forecast took 19.2 s of Home Assistant's main thread in 15 minutes
131
- * and the times were mostly worked out again (torrent-tv/meta#178).
160
+ * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `planEncodersSoon`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `placeViewerOn`, `chooseAudioTrack`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
132
161
  */
133
- #forecastCosts = { since: Date.now(), polls: 0, ms: 0, maxMs: 0, times: new Map() };
162
+ constructor(host) {
163
+ this.#host = host;
164
+ }
134
165
 
135
- #noteForecast(ms, reasons) {
136
- const costs = this.#forecastCosts;
137
- costs.polls += 1; costs.ms += ms; costs.maxMs = Math.max(costs.maxMs, ms);
138
- for (const reason of reasons) costs.times.set(reason, (costs.times.get(reason) ?? 0) + 1);
139
- const now = Date.now();
140
- if (now - costs.since < 60_000) return;
141
- logger.info(`start forecasts in ${((now - costs.since) / 1000).toFixed(1)}s: ${costs.polls}, ` +
142
- `${Math.round(costs.ms)}ms in all, ${(costs.ms / costs.polls).toFixed(1)}ms each, longest ${Math.round(costs.maxMs)}ms; ` +
143
- `times of a track's pieces: ${[...costs.times].map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
144
- Object.assign(costs, { since: now, polls: 0, ms: 0, maxMs: 0, times: new Map() });
166
+ viewerDeparted(viewer) {
167
+ if (viewer) this.#host.viewerPlaybackState.forgetViewer(viewer);
145
168
  }
146
169
 
147
- /**
148
- * The pieces of each output as the start forecast read them last (see
149
- * `#segmentsOf`), and what the forecast keeps of their time geometry while
150
- * they stand (`playback-readiness.js` `timescaleOfPieces`).
151
- *
152
- * @type {WeakMap<object, { pieces: object[], geometry: object }>}
153
- */
154
- #forecastPieces = new WeakMap();
170
+ viewerTokenOf(consumerId) {
171
+ return consumerId ? this.#host.viewers.get(consumerId) : null;
172
+ }
155
173
 
156
- /**
157
- * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `planEncodersSoon`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `placeViewerOn`, `chooseAudioTrack`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
158
- */
159
- constructor(host) {
160
- this.#host = host;
174
+ outputViewerDeparted(viewer, output) {
175
+ if (!viewer || !output) return;
176
+ if (this.#host.viewers.get(viewer.id) === viewer) {
177
+ this.#host.viewerPlaybackState.forgetViewerOutput(viewer, output);
178
+ } else {
179
+ this.#host.viewerPlaybackState.forgetViewer(viewer);
180
+ }
181
+ }
182
+
183
+ outputDisposed(output) {
184
+ this.#host.viewerPlaybackState.forgetOutput(output);
185
+ this.#host.outputForecastGeometry.forget(output);
161
186
  }
162
187
 
163
188
  /** Record the facts known while listing a source, without producing an ETA. */
164
- noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs }) {
189
+ noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
190
+ viewerToken }) {
165
191
  if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
166
192
  !Number.isSafeInteger(fileIndex) || fileIndex < 0) return;
167
193
  const waitId = `${sourceKey}:${fileIndex}:${generation}`;
168
- const key = `${consumerId}:${waitId}`;
194
+ const viewer = this.#currentViewerToken(consumerId, viewerToken);
195
+ if (!viewer) return;
169
196
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
170
197
  const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
171
198
  const features = {
@@ -179,10 +206,8 @@ export class ViewerRequests {
179
206
  headerRemaining: null,
180
207
  peers: null
181
208
  };
182
- const current = this.#waitObservations.get(key);
183
- const context = this.#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current });
184
- this.#recordWaitSnapshot(context);
185
- this.#pruneWaitObservations();
209
+ this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
210
+ this.#host.viewerPlaybackState.pruneWaits();
186
211
  }
187
212
 
188
213
  /**
@@ -194,10 +219,11 @@ export class ViewerRequests {
194
219
  * @returns {object | null}
195
220
  */
196
221
  noteWaitSourceSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
197
- stats = {}, linkMbps, linkSampleAgeMs, processing = {} }) {
222
+ stats = {}, linkMbps, linkSampleAgeMs, processing = {}, viewerToken }) {
198
223
  if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return;
199
224
  const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
200
- const key = `${consumerId}:${waitId}`;
225
+ const viewer = this.#currentViewerToken(consumerId, viewerToken);
226
+ if (!viewer) return;
201
227
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
202
228
  const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
203
229
  const features = {
@@ -218,10 +244,8 @@ export class ViewerRequests {
218
244
  videoSpeedX: processing.videoSpeedX ?? null,
219
245
  audioSpeedX: processing.audioSpeedX ?? null
220
246
  };
221
- const current = this.#waitObservations.get(key);
222
- const context = this.#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current });
223
- this.#recordWaitSnapshot(context);
224
- this.#pruneWaitObservations();
247
+ this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
248
+ this.#host.viewerPlaybackState.pruneWaits();
225
249
  }
226
250
 
227
251
  /**
@@ -235,49 +259,14 @@ export class ViewerRequests {
235
259
  * @returns {void}
236
260
  */
237
261
  noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs, stages = null }) {
238
- const key = `${consumerId}:${waitId}`;
239
- const observation = this.#waitObservations.get(key);
240
- if (!observation || observation.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || observation.observedWaits.has(wait) ||
241
- !Number.isFinite(elapsedMs) || elapsedMs < 0) return;
242
- const initialFeatures = observation.snapshots[0];
243
- if (initialFeatures) {
244
- this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
245
- featureSnapshots: observation.snapshots.slice(1), stages });
246
- }
247
- observation.observedWaits.add(wait);
248
- observation.updatedAt = Date.now();
249
- }
250
-
251
- #setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current }) {
252
- const sameWait = current?.waitId === waitId;
253
- const context = sameWait ? current : {
254
- consumerId,
255
- generation,
256
- sourceKey,
257
- fileIndex,
258
- waitId,
259
- snapshots: [],
260
- observedWaits: new Set()
261
- };
262
- const measuredAt = performance.now();
263
- const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
264
- ? Math.max(0, measuredAt - context.featuresMeasuredAt)
265
- : 0;
266
- const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
267
- Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
268
- featuresMeasuredAt: measuredAt, updatedAt: Date.now() });
269
- this.#waitObservations.set(`${consumerId}:${waitId}`, context);
270
- return context;
262
+ const viewer = this.#host.viewers.get(consumerId);
263
+ if (!viewer) return;
264
+ this.#host.viewerPlaybackState.noteFirstFrame({ viewer, generation, waitId, wait, elapsedMs, stages,
265
+ record: observation => this.#host.noteFirstFrame?.({ ...observation, stages }) });
271
266
  }
272
267
 
273
- #recordWaitSnapshot(context) {
274
- const features = context.features;
275
- const previous = context.snapshots.at(-1);
276
- if (previous && JSON.stringify(previous) === JSON.stringify(features)) return false;
277
- const snapshot = { ...features };
278
- if (context.snapshots.length < 2) context.snapshots.push(snapshot);
279
- else context.snapshots[1] = snapshot;
280
- return true;
268
+ #currentViewerToken(consumerId, viewerToken) {
269
+ return viewerToken && this.#host.viewers.get(consumerId) === viewerToken ? viewerToken : null;
281
270
  }
282
271
 
283
272
  #preparedOutputBytes(sourceKey, fileIndex) {
@@ -307,14 +296,6 @@ export class ViewerRequests {
307
296
  return bytes;
308
297
  }
309
298
 
310
- #pruneWaitObservations() {
311
- const cutoff = Date.now() - 30 * 60_000;
312
- for (const [key, value] of this.#waitObservations) {
313
- if (value.updatedAt < cutoff) this.#waitObservations.delete(key);
314
- }
315
- while (this.#waitObservations.size > 1000) this.#waitObservations.delete(this.#waitObservations.keys().next().value);
316
- }
317
-
318
299
  /**
319
300
  * Open an output of a file for this viewer and put them on it.
320
301
  *
@@ -548,10 +529,10 @@ export class ViewerRequests {
548
529
  if (!Number.isSafeInteger(generation) || !file || typeof file.sourceKey !== "string" ||
549
530
  !Number.isSafeInteger(file.fileIndex) || file.fileIndex < 0) return;
550
531
  const waitId = `${file.sourceKey}:${file.fileIndex}:${generation}`;
551
- const key = `${consumerId}:${waitId}`;
552
532
  const fileName = file.relativePath ?? file.name ?? "";
553
533
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
554
534
  const viewer = this.#host.viewers.get(consumerId);
535
+ if (!viewer) return;
555
536
  const link = linkMeasurementOf(viewer);
556
537
  const features = {
557
538
  extension,
@@ -570,10 +551,9 @@ export class ViewerRequests {
570
551
  };
571
552
  // A seek is a new observation group even when a caller repeats the same
572
553
  // generation; its source facts must not join observations from the prior wait.
573
- this.#waitObservations.delete(key);
574
- const context = this.#setWaitObservation({ consumerId, generation, sourceKey: file.sourceKey,
575
- fileIndex: file.fileIndex, waitId, features, current: null });
576
- this.#recordWaitSnapshot(context);
554
+ this.#host.viewerPlaybackState.deleteWait(viewer, waitId);
555
+ this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey: file.sourceKey,
556
+ fileIndex: file.fileIndex, waitId, features });
577
557
  }
578
558
 
579
559
  /**
@@ -675,7 +655,7 @@ export class ViewerRequests {
675
655
  return this.#host.viewerSecondsOn(session, consumerId);
676
656
  }
677
657
 
678
- async getSessionProgress(sessionId, consumerId = "") {
658
+ async getSessionProgress(sessionId, consumerId = "", statedGeneration = Number.NaN) {
679
659
  if (!isOutputName(sessionId)) {
680
660
  return null;
681
661
  }
@@ -690,6 +670,14 @@ export class ViewerRequests {
690
670
  // without this the base session would idle out from under its own variants.
691
671
  const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
692
672
  this.#host.outputs.touch(session);
673
+ const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
674
+ const requestGeneration = consumerId && viewer?.assignments
675
+ ? this.#host.generationOfRequest(consumerId) : null;
676
+ const requestGenerationIsStated = Number.isSafeInteger(statedGeneration);
677
+ if (requestGenerationIsStated && requestGeneration !== statedGeneration) return { superseded: true };
678
+ const requestGenerationIsCurrent = () => !Number.isSafeInteger(requestGeneration) ||
679
+ this.#host.generationOfRequest(consumerId) === requestGeneration;
680
+ const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
693
681
  const audio = this.#host.renditions.servesAudioSeparately(session)
694
682
  ? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
695
683
  const failed = [session, audio].find(output => output && this.#host.encodeRuns.wireStateOf(output) === "failed");
@@ -709,8 +697,13 @@ export class ViewerRequests {
709
697
  // Observed output bitrate (Mbit/s) from recently completed segments. The
710
698
  // proxy uses it for the viewer-link budget and the readiness forecast.
711
699
  const outputMbps = await this.#host.quality.observedStreamMbps(session);
700
+ if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
701
+ if (!stillOwnsProgress()) return null;
712
702
  const progress = this.#host.encodeRuns.progressOf(session);
713
- const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps);
703
+ const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
704
+ viewer, stillOwnsProgress);
705
+ if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
706
+ if (!stillOwnsProgress()) return null;
714
707
  return {
715
708
  // The id the caller asked about, not the variant it was answered from —
716
709
  // the browser tracks its sessions by the id it was given.
@@ -795,7 +788,16 @@ export class ViewerRequests {
795
788
  };
796
789
  }
797
790
 
798
- async #playbackReadinessFor(session, consumerId, progress, outputMbps) {
791
+ #stillOwnsProgress(named, session, consumerId, viewer) {
792
+ if (this.#host.outputs.get(named.id) !== named || this.#host.outputs.get(session.id) !== session) return false;
793
+ if (!viewer) return !consumerId || !this.#host.viewers.get(consumerId);
794
+ return this.#host.viewers.get(consumerId) === viewer &&
795
+ (!(viewer.outputs instanceof Set) || viewer.outputs.has(named.id)) &&
796
+ this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs }) === session;
797
+ }
798
+
799
+ async #playbackReadinessFor(session, consumerId, progress, outputMbps, viewer, stillOwnsProgress) {
800
+ if (!stillOwnsProgress()) return null;
799
801
  const waitStartedAt = performance.now();
800
802
  const waitFields = { process: "P14", sessionId: session.id, consumerId };
801
803
  this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
@@ -806,13 +808,32 @@ export class ViewerRequests {
806
808
  // held viewer's answer took 11.2 s while its forecast took 0.1-0.4 s and
807
809
  // the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
808
810
  const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
809
- const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
810
811
  const viewerReading = viewer?.linkReading() ?? null;
811
- const measurement = this.#rateStateFor(session, consumerId);
812
+ const generation = consumerId && viewer?.assignments
813
+ ? this.#host.generationOfRequest(consumerId) : null;
814
+ const actionIsCurrent = () => !Number.isSafeInteger(generation) ||
815
+ this.#host.generationOfRequest(consumerId) === generation;
816
+ const actionId = Number.isSafeInteger(generation)
817
+ ? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
818
+ const measurement = viewer
819
+ ? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
820
+ : { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
821
+ const existingLinkCaptures = measurement.link.captureSnapshot();
822
+ if (viewer && (viewerReading?.linkCapture?.validity !== "valid" || existingLinkCaptures.some(capture =>
823
+ !capture || capture.clockEpoch !== viewerReading.linkCapture.clockEpoch))) {
824
+ this.#host.viewerPlaybackState.forgetLinkReadings(measurement, this.#host.playbackReadiness,
825
+ this.#host.lookaheadSeconds);
826
+ }
812
827
  const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
813
- if (Number.isFinite(viewerReading?.linkSampleMbps) && viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
814
- measurement.link.add(linkAt, viewerReading.linkSampleMbps * 1_000_000);
828
+ if (viewerReading?.linkCapture?.validity === "valid" && Number.isFinite(viewerReading?.linkSampleMbps) &&
829
+ viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
830
+ this.#host.viewerPlaybackState.addLinkReading(measurement, linkAt, viewerReading.linkSampleMbps,
831
+ viewerReading.linkCapture);
815
832
  }
833
+ const rawBufferCapture = viewerReading?.bufferCapture ?? unknownCapture("browser", actionId, generation,
834
+ "browser-buffer-capture-unavailable");
835
+ const bufferCaptureValid = rawBufferCapture.validity === "valid" && rawBufferCapture.actionId === session.id &&
836
+ rawBufferCapture.generation === generation;
816
837
 
817
838
  const outputs = [session];
818
839
  const requiresSeparateAudio = this.#host.renditions.servesAudioSeparately(session);
@@ -828,20 +849,24 @@ export class ViewerRequests {
828
849
  let primarySourceStats = null;
829
850
  const tracks = [];
830
851
  for (const output of outputs) {
852
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
831
853
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
832
- const trackRates = this.#trackRateReadings(output, now);
854
+ const trackRate = this.#trackRateReadings(output, now);
855
+ const trackRates = trackRate?.summary ?? null;
833
856
  const trackName = output === session ? "video" : "audio";
834
857
  // The page states the offset its player applied to this track, and the
835
858
  // init it applied it to is the session's. Until both are known, the
836
859
  // pieces stay on their own presentation timeline: there is nothing yet
837
860
  // to place them by.
838
- const playerOffset = viewerReading?.timestampOffsets?.[trackName];
861
+ const playerOffset = bufferCaptureValid ? viewerReading?.timestampOffsets?.[trackName] : null;
862
+ const reportedPosition = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
863
+ ? viewerReading.positionSeconds : null;
839
864
  const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
840
865
  const placedByPlayer = Boolean(servedInit?.length);
841
866
  const sessionInit = placedByPlayer ? servedInit : null;
842
867
  const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
843
- const segments = this.#segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
844
- ? viewerReading.positionSeconds : this.#host.viewerSecondsOn(session, consumerId, now));
868
+ const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
869
+ Number.isFinite(reportedPosition) ? reportedPosition : this.#host.viewerSecondsOn(session, consumerId, now));
845
870
  const sourceIndexes = [...output.spec.sourceFileIndexes];
846
871
  if (sourceIndexes.length === 0) {
847
872
  sourceIndexes.push(output.file.fileIndex);
@@ -861,30 +886,62 @@ export class ViewerRequests {
861
886
  } catch {
862
887
  stats = null;
863
888
  }
889
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
864
890
  if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
865
891
  parts.statsMs += performance.now() - statsAt;
866
892
  parts.workerForecastMs += Number(stats?.forecastMs) || 0;
893
+ const sourceCapture = sourceCaptureOf(stats, actionId, generation);
867
894
  if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
868
895
  const sharedForecast = sourceForecasts.get(output.file.sourceKey);
869
- const downloadForecast = sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
896
+ const capturedForecast = sourceCaptureOf({ sourceCapture: sharedForecast?.capture }, actionId, generation);
897
+ const forecastCapture = sharedForecast?.global === true && !Array.isArray(sharedForecast.ranges)
898
+ ? { ...capturedForecast, validity: "unknown", unknownReason: "source-arrival-ranges-unavailable" }
899
+ : capturedForecast;
900
+ const downloadForecast = sourceCapture.validity !== "valid" || forecastCapture.validity !== "valid"
901
+ ? { ranges: [] } : sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
870
902
  ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
871
903
  start: Math.max(0, range.start - stats.fileOffset),
872
904
  end: Math.min(stats.fileLength, range.end - stats.fileOffset)
873
905
  })).filter(range => range.end > range.start) } : sharedForecast;
906
+ const sourceFactsValid = sourceCapture.validity === "valid";
907
+ const ownsSourceState = viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement);
908
+ const sourceReadingsValid = sourceFactsValid && Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0;
909
+ const existingSourceCaptures = ownsSourceState
910
+ ? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
911
+ const sourceEpochChanged = sourceReadingsValid && existingSourceCaptures &&
912
+ [existingSourceCaptures.recent?.delivered, existingSourceCaptures.recent?.free,
913
+ existingSourceCaptures.tail?.delivered, existingSourceCaptures.tail?.free].some(capture =>
914
+ capture?.validity === "valid" && capture.clockEpoch !== sourceCapture.clockEpoch);
915
+ if (ownsSourceState && (!sourceReadingsValid || sourceEpochChanged)) {
916
+ this.#host.viewerPlaybackState.forgetSourceReadings(measurement, output.file.sourceKey);
917
+ }
918
+ const downloadRateReadings = ownsSourceState && sourceReadingsValid
919
+ ? this.#host.viewerPlaybackState.sourceRateReadings(measurement, output.file.sourceKey, stats, now,
920
+ this.#host.playbackReadiness, this.#host.lookaheadSeconds, sourceCapture) : null;
921
+ const tailRateReadings = ownsSourceState && sourceReadingsValid
922
+ ? this.#host.viewerPlaybackState.sourceTailRateReadings(measurement, output.file.sourceKey, now,
923
+ this.#host.playbackReadiness) : null;
924
+ const downloadRateSummaries = ownsSourceState && sourceReadingsValid
925
+ ? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null;
926
+ const rateCapture = ownsSourceState && sourceReadingsValid
927
+ ? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
874
928
  sourceMeasurements.set(sourceId, {
875
929
  id: sourceId,
876
930
  serviceKey: output.file.sourceKey,
877
- complete: stats?.fileAvailable === true,
878
- residence: stats?.residence,
879
- fileOffset: stats?.fileOffset,
880
- fileLength: stats?.fileLength,
881
- bytesPerMediaSecond: Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
931
+ complete: sourceFactsValid ? stats?.fileAvailable === true : null,
932
+ residence: sourceFactsValid ? stats?.residence : null,
933
+ fileOffset: sourceFactsValid ? stats?.fileOffset : null,
934
+ fileLength: sourceFactsValid ? stats?.fileLength : null,
935
+ bytesPerMediaSecond: sourceFactsValid && Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
882
936
  Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
883
937
  ? stats.fileLength / session.file.durationSeconds : null,
884
- pieceLength: stats?.pieceLength,
885
- downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
886
- tailRateReadings: this.#sourceTailRateReadings(measurement, output.file.sourceKey, now),
887
- downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
938
+ pieceLength: sourceFactsValid ? stats?.pieceLength : null,
939
+ downloadRateReadings,
940
+ tailRateReadings,
941
+ downloadRateSummaries,
942
+ capture: { ...sourceCapture, rateReadings: rateCapture ?? {
943
+ validity: "unknown", unknownReason: "source-rate-capture-unavailable" },
944
+ downloadForecast: forecastCapture },
888
945
  downloadForecast,
889
946
  });
890
947
  }
@@ -893,35 +950,38 @@ export class ViewerRequests {
893
950
  const observedMbps = output === session
894
951
  ? outputMbps
895
952
  : await this.#host.quality.observedStreamMbps(output);
953
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
896
954
  parts.qualityMs += performance.now() - qualityAt;
897
955
  const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
956
+ const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
957
+ const encoderCaptureValid = encoderCapture.validity === "valid";
898
958
  const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
899
959
  const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
900
960
  tracks.push({
901
961
  id: output.outputKey,
902
962
  clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
903
- clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
904
- viewerReading?.bufferedRanges?.media,
963
+ clientRanges: bufferCaptureValid ? viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
964
+ viewerReading?.bufferedRanges?.media : null,
905
965
  // Browser-observed append and fragment-parse samples are retained for
906
966
  // the full model's service schedule; they are not yet used as numeric
907
967
  // predecessors in the partial continuity calculation.
908
- appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
968
+ appendService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
909
969
  ? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
910
970
  epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
911
971
  receivedAt: viewerReading.appendServiceReceivedAt }
912
972
  : null,
913
- fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
973
+ fragmentService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
914
974
  ? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
915
975
  epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
916
976
  receivedAt: viewerReading.appendServiceReceivedAt }
917
977
  : null,
918
978
  sourceIds,
919
- processedSeconds: trackProgress?.processedSeconds,
920
- startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
979
+ processedSeconds: encoderCaptureValid ? trackProgress?.processedSeconds : null,
980
+ startupRemainingSeconds: !encoderCaptureValid ? null : processingStarted ? 0 : Math.max(0,
921
981
  (this.#host.processingStartupSecondsOf?.(output) ?? 0) - Math.max(0, (now - startedAt) / 1000)),
922
- processingRanges: liveRuns.map(({ progress }) => ({
982
+ processingRanges: encoderCaptureValid ? liveRuns.map(({ progress }) => ({
923
983
  start: progress.startPositionSeconds, end: progress.processedSeconds
924
- })),
984
+ })) : [],
925
985
  // What a run's start costs this output, and the stretches its live
926
986
  // runs were given: the forecast starts a run wherever production meets
927
987
  // bytes that were not held when the run before was admitted.
@@ -934,16 +994,19 @@ export class ViewerRequests {
934
994
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
935
995
  durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
936
996
  segmentCount: output.timeline?.segmentCount ?? segments.length,
937
- readings: trackRates,
997
+ readings: encoderCaptureValid ? trackRates : [],
998
+ capture: encoderCapture,
938
999
  segments,
939
- geometry: this.#forecastPieces.get(output)?.geometry,
1000
+ geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
940
1001
  readySegmentIndices: [...inventory.keys()],
941
1002
  segmentSizesBytes: inventory
942
1003
  });
943
1004
  }
944
1005
 
945
- const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
946
- const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
1006
+ if (!stillOwnsProgress() || !actionIsCurrent()) return null;
1007
+
1008
+ const bufferedAheadSeconds = bufferCaptureValid ? Number(viewerReading?.bufferedAheadSec) : null;
1009
+ const positionSeconds = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
947
1010
  ? viewerReading.positionSeconds
948
1011
  : this.#host.viewerSecondsOn(session, consumerId, now);
949
1012
  // Both separately delivered tracks can run short independently. Use the
@@ -970,7 +1033,7 @@ export class ViewerRequests {
970
1033
  positionSeconds,
971
1034
  durationSeconds: session.file.durationSeconds,
972
1035
  bufferedAheadSeconds,
973
- bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
1036
+ bufferLimitSeconds: bufferCaptureValid ? viewerReading?.bufferLimitSeconds : null,
974
1037
  reserveSeconds,
975
1038
  lookaheadSeconds: this.#host.lookaheadSeconds,
976
1039
  // Disk room remaining after stock reserved for other active outputs.
@@ -978,7 +1041,7 @@ export class ViewerRequests {
978
1041
  requiredAudio: requiresSeparateAudio,
979
1042
  sources: [...sourceMeasurements.values()],
980
1043
  tracks,
981
- linkReadings: measurement.link.snapshot()
1044
+ linkReadings: viewerReading?.linkCapture?.validity === "valid" ? measurement.link.snapshot() : null
982
1045
  };
983
1046
  // A subtitle track chosen inside THIS file is the last input the forecast
984
1047
  // waits for. A subtitle FILE beside it is named by its own index and is
@@ -991,13 +1054,25 @@ export class ViewerRequests {
991
1054
  this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
992
1055
  }
993
1056
  const predictMs = performance.now() - startedAt;
994
- this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
1057
+ this.#host.noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
995
1058
  logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
996
1059
  `(the torrent thread's arrival forecast ${Math.round(parts.workerForecastMs)}ms), stream rate ${Math.round(parts.qualityMs)}ms, ` +
997
1060
  `start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
998
1061
  const forecast = withSubtitleReadiness(predicted, subtitle,
999
1062
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
1000
1063
  forecast.snapshotAtProxyMs = startedAt;
1064
+ forecast.snapshotClockEpoch = performance.timeOrigin;
1065
+ forecast.inputCaptures = {
1066
+ source: [...sourceMeasurements].map(([id, source]) => ({ id, capture: source.capture })),
1067
+ link: { ...(viewerReading?.linkCapture ?? unknownCapture("browser", actionId, generation,
1068
+ "link-capture-unavailable")), rateReadings: measurement.link.captureSnapshot() },
1069
+ encoder: tracks.map(track => ({ id: track.id, capture: track.capture })),
1070
+ buffer: bufferCaptureValid || rawBufferCapture.validity === "unknown" ? rawBufferCapture : {
1071
+ ...rawBufferCapture, validity: "invalid",
1072
+ unknownReason: rawBufferCapture.generation !== generation ? "seek-generation-replaced" :
1073
+ rawBufferCapture.actionId !== session.id ? "viewer-session-replaced" : "viewer-output-cancelled"
1074
+ }
1075
+ };
1001
1076
  forecast.operations = outputs.flatMap((output, index) => {
1002
1077
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
1003
1078
  processedSeconds: tracks[index].processedSeconds,
@@ -1014,11 +1089,9 @@ export class ViewerRequests {
1014
1089
  outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
1015
1090
  ];
1016
1091
  });
1017
- const generation = consumerId && viewer?.assignments
1018
- ? this.#host.generationOfRequest(consumerId) : null;
1019
1092
  const earlyWaitId = Number.isSafeInteger(generation)
1020
1093
  ? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
1021
- if (earlyWaitId && this.#waitObservations.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
1094
+ if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
1022
1095
  const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
1023
1096
  const videoOperation = forecast.operations.find(operation => operation.track === "video");
1024
1097
  const audioOperation = forecast.operations.find(operation => operation.track === "audio");
@@ -1026,6 +1099,7 @@ export class ViewerRequests {
1026
1099
  sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
1027
1100
  fileName: session.file.relativePath ?? session.file.name ?? "",
1028
1101
  fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
1102
+ viewerToken: viewer,
1029
1103
  linkMbps: viewerReading?.linkMbps,
1030
1104
  linkSampleAgeMs: Number.isFinite(linkMeasuredAt) ? Math.max(0, Date.now() - linkMeasuredAt) : undefined,
1031
1105
  processing: {
@@ -1163,85 +1237,6 @@ export class ViewerRequests {
1163
1237
  * @param {number} positionSeconds
1164
1238
  * @returns {object[]}
1165
1239
  */
1166
- #segmentsOf(output, sessionInit, inventory, positionSeconds) {
1167
- const timeline = output.timeline;
1168
- const count = timeline?.segmentCount ?? 0;
1169
- let forecast = this.#forecastPieces.get(output);
1170
- if (!forecast) {
1171
- forecast = { pieces: [], geometry: {} };
1172
- this.#forecastPieces.set(output, forecast);
1173
- }
1174
- const kept = forecast.pieces;
1175
- const segments = [];
1176
- const lookahead = Number(this.#host.lookaheadSeconds);
1177
- const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
1178
- const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
1179
- let low = 0, high = count;
1180
- while (low < high) {
1181
- const middle = (low + high) >>> 1;
1182
- if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
1183
- else high = middle;
1184
- }
1185
- const first = Math.max(0, low - 1);
1186
- low = first;
1187
- high = count;
1188
- while (low < high) {
1189
- const middle = (low + high) >>> 1;
1190
- if (timeline.publishedStartOf(middle) < end) low = middle + 1;
1191
- else high = middle;
1192
- }
1193
- const last = low;
1194
- const indexes = new Set([0]);
1195
- for (let index = first; index < last; index++) indexes.add(index);
1196
- for (const index of indexes) {
1197
- const endSeconds = timeline.publishedStartOf(index + 1);
1198
- // Only a made piece has media intervals to read. Asked of every piece,
1199
- // each one not made cost a `stat` of its file in the store's safety net
1200
- // for a missed directory event: 1327 per poll per output, 12.6 s of 300 s
1201
- // of the main thread at a cold start on Home Assistant (r61,
1202
- // torrent-tv/meta#178), while requests waited behind it.
1203
- const ranges = inventory.has(index) ? this.#host.encodeRuns.finishedMediaRangesOf(output, index) : undefined;
1204
- const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
1205
- output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
1206
- // The player can request a fragment whose media timestamps extend a few
1207
- // frames past its nominal playlist end while seeking to the next piece.
1208
- if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
1209
- Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) &&
1210
- !this.#host.playbackReadiness.endsWithinPlayerSeekTolerance(endSeconds, positionSeconds) &&
1211
- endSeconds < positionSeconds) {
1212
- continue;
1213
- }
1214
- const startSeconds = timeline.publishedStartOf(index);
1215
- const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
1216
- const previous = kept[index];
1217
- if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
1218
- previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
1219
- segments.push(previous);
1220
- continue;
1221
- }
1222
- const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
1223
- kept[index] = segment;
1224
- segments.push(segment);
1225
- }
1226
- if (kept.length > count) kept.length = count;
1227
- return segments;
1228
- }
1229
-
1230
- #rateStateFor(session, consumerId) {
1231
- let byViewer = this.#rateStateBySession.get(session);
1232
- if (!byViewer) {
1233
- byViewer = new Map();
1234
- this.#rateStateBySession.set(session, byViewer);
1235
- }
1236
- const id = typeof consumerId === "string" ? consumerId : "";
1237
- let state = byViewer.get(id);
1238
- if (!state) {
1239
- state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
1240
- byViewer.set(id, state);
1241
- }
1242
- return state;
1243
- }
1244
-
1245
1240
  /**
1246
1241
  * The encoder's rate for the forecast: the output's whole window of readings,
1247
1242
  * summarized again on each poll. A run that ended between two polls took its
@@ -1252,7 +1247,20 @@ export class ViewerRequests {
1252
1247
  #trackRateReadings(output, now) {
1253
1248
  const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
1254
1249
  const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
1255
- if (summary) return summary;
1250
+ if (summary) return { summary, captures: readings.map(reading => ({ owner: reading.captureOwner,
1251
+ clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
1252
+ startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
1253
+ revision: reading.at,
1254
+ validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
1255
+ Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
1256
+ Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
1257
+ reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
1258
+ ? "valid" : "unknown",
1259
+ unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
1260
+ Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
1261
+ Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
1262
+ reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
1263
+ ? null : "encoder-sample-capture-unavailable" })) };
1256
1264
  // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
1257
1265
  // encoder, and it is not kept among the readings. Added on every poll
1258
1266
  // between runs, it put the machine's share of that moment — 1 % at a
@@ -1262,7 +1270,9 @@ export class ViewerRequests {
1262
1270
  if (Number.isFinite(projected) && projected > 0) {
1263
1271
  const seed = new this.#host.playbackReadiness.RateTrend();
1264
1272
  seed.add(now, projected);
1265
- return seed.snapshot();
1273
+ return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
1274
+ clockEpoch: performance.timeOrigin, startedAt: now, completedAt: now, revision: now,
1275
+ validity: "valid", unknownReason: null }] };
1266
1276
  }
1267
1277
  // Progress over wall time includes source waits, which the forecast prices
1268
1278
  // separately. Keep the rate unknown until a measured or calibrated
@@ -1270,93 +1280,6 @@ export class ViewerRequests {
1270
1280
  return null;
1271
1281
  }
1272
1282
 
1273
- /**
1274
- * What the swarm can give this source. A rate measured while our own
1275
- * selection held a connection back measures our asking: on a cold Matroska
1276
- * it read 0.4-1.3 MB/s while the same swarm later gave 12-14 MB/s, and the
1277
- * forecast swung from 0.9 s to 19 075 s (torrent-tv/meta#178). So it is read
1278
- * from the time no connection was held back (`SelectionLimit`), and a later
1279
- * such reading lowers it as readily as it raises it. Until there is one, the
1280
- * rate delivered stands, which is the least the swarm can give.
1281
- */
1282
- #sourceRateReadings(state, id, stats, now) {
1283
- let source = state.sources.get(id);
1284
- if (!source) {
1285
- const { RateTrend, SessionRate } = this.#host.playbackReadiness;
1286
- source = { delivered: new RateTrend(this.#host.lookaheadSeconds), free: new RateTrend(this.#host.lookaheadSeconds),
1287
- // The same readings over the whole session, which the rest of the film is priced by.
1288
- deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
1289
- state.sources.set(id, source);
1290
- }
1291
- if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
1292
- source.delivered.add(now, stats.downloadSpeed);
1293
- source.deliveredAll.add(now, stats.downloadSpeed);
1294
- }
1295
- const limit = stats?.selectionLimit;
1296
- if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
1297
- const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
1298
- source.free.add(now, free);
1299
- source.freeAll.add(now, free);
1300
- }
1301
- // Totals that went back belong to a torrent added again: read on from them.
1302
- if (limit) source.limit = limit;
1303
- return sourceService(source.free.snapshot(), source.delivered.snapshot(),
1304
- (summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
1305
- }
1306
-
1307
- /** The source's service over the whole session, chosen by the same rule as the recent one. */
1308
- #sourceTailRateReadings(state, id, now) {
1309
- const source = state.sources.get(id);
1310
- if (!source) return null;
1311
- return sourceService(source.freeAll.snapshot(), source.deliveredAll.snapshot(),
1312
- (summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
1313
- }
1314
-
1315
- /** Both summaries a source's service was chosen from, for the forecast's log line. */
1316
- #sourceRateSummaries(state, id) {
1317
- const source = state.sources.get(id);
1318
- return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
1319
- }
1320
- }
1321
-
1322
- /**
1323
- * Keep a wait's measurements when a later response omits a field. Zero is a
1324
- * measurement for rates, peers and byte counts; only null or an invalid value
1325
- * means that this response did not measure the field.
1326
- *
1327
- * @param {object | null | undefined} previous
1328
- * @param {object} incoming
1329
- * @param {number} ageElapsedMs
1330
- * @returns {object}
1331
- */
1332
- function mergeWaitFeatures(previous, incoming, ageElapsedMs) {
1333
- const merged = { ...(previous ?? {}) };
1334
- for (const [key, value] of Object.entries(incoming ?? {})) {
1335
- if (value === null || value === undefined) {
1336
- if (!Object.hasOwn(merged, key)) merged[key] = null;
1337
- continue;
1338
- }
1339
- if (key === "extension") {
1340
- if (typeof value === "string" && value.length > 0) merged[key] = value;
1341
- continue;
1342
- }
1343
- if (key === "fileAvailable") {
1344
- if (typeof value === "boolean") merged[key] = value;
1345
- continue;
1346
- }
1347
- if (key === "videoOperation" || key === "audioOperation") {
1348
- if (value === "copy" || value === "encode") merged[key] = value;
1349
- continue;
1350
- }
1351
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0 ||
1352
- ((key === "fileLength" || key === "linkMbps") && value === 0)) continue;
1353
- merged[key] = value;
1354
- }
1355
- const hasIncomingAge = Number.isFinite(incoming?.linkSampleAgeMs) && incoming.linkSampleAgeMs >= 0;
1356
- if (Number.isFinite(merged.linkSampleAgeMs) && !hasIncomingAge) {
1357
- merged.linkSampleAgeMs += Number.isFinite(ageElapsedMs) ? Math.max(0, ageElapsedMs) : 0;
1358
- }
1359
- return merged;
1360
1283
  }
1361
1284
 
1362
1285
  /**
@@ -1399,8 +1322,3 @@ function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
1399
1322
  * @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
1400
1323
  * @returns {object | null}
1401
1324
  */
1402
- export function sourceService(free, delivered, rateOf) {
1403
- if (!free) return delivered;
1404
- if (!delivered) return free;
1405
- return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
1406
- }