@torrent-tv/proxy 2.107.51 → 2.107.53

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, {
@@ -119,53 +113,43 @@ function segmentAddressOf(output, fileName) {
119
113
  export class ViewerRequests {
120
114
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
121
115
  #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
116
  /**
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).
117
+ * @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
118
  */
133
- #forecastCosts = { since: Date.now(), polls: 0, ms: 0, maxMs: 0, times: new Map() };
119
+ constructor(host) {
120
+ this.#host = host;
121
+ }
134
122
 
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() });
123
+ viewerDeparted(viewer) {
124
+ if (viewer) this.#host.viewerPlaybackState.forgetViewer(viewer);
145
125
  }
146
126
 
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();
127
+ viewerTokenOf(consumerId) {
128
+ return consumerId ? this.#host.viewers.get(consumerId) : null;
129
+ }
155
130
 
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;
131
+ outputViewerDeparted(viewer, output) {
132
+ if (!viewer || !output) return;
133
+ if (this.#host.viewers.get(viewer.id) === viewer) {
134
+ this.#host.viewerPlaybackState.forgetViewerOutput(viewer, output);
135
+ } else {
136
+ this.#host.viewerPlaybackState.forgetViewer(viewer);
137
+ }
138
+ }
139
+
140
+ outputDisposed(output) {
141
+ this.#host.viewerPlaybackState.forgetOutput(output);
142
+ this.#host.outputForecastGeometry.forget(output);
161
143
  }
162
144
 
163
145
  /** Record the facts known while listing a source, without producing an ETA. */
164
- noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs }) {
146
+ noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
147
+ viewerToken }) {
165
148
  if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
166
149
  !Number.isSafeInteger(fileIndex) || fileIndex < 0) return;
167
150
  const waitId = `${sourceKey}:${fileIndex}:${generation}`;
168
- const key = `${consumerId}:${waitId}`;
151
+ const viewer = this.#currentViewerToken(consumerId, viewerToken);
152
+ if (!viewer) return;
169
153
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
170
154
  const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
171
155
  const features = {
@@ -179,10 +163,8 @@ export class ViewerRequests {
179
163
  headerRemaining: null,
180
164
  peers: null
181
165
  };
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();
166
+ this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
167
+ this.#host.viewerPlaybackState.pruneWaits();
186
168
  }
187
169
 
188
170
  /**
@@ -194,10 +176,11 @@ export class ViewerRequests {
194
176
  * @returns {object | null}
195
177
  */
196
178
  noteWaitSourceSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
197
- stats = {}, linkMbps, linkSampleAgeMs, processing = {} }) {
179
+ stats = {}, linkMbps, linkSampleAgeMs, processing = {}, viewerToken }) {
198
180
  if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return;
199
181
  const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
200
- const key = `${consumerId}:${waitId}`;
182
+ const viewer = this.#currentViewerToken(consumerId, viewerToken);
183
+ if (!viewer) return;
201
184
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
202
185
  const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
203
186
  const features = {
@@ -218,10 +201,8 @@ export class ViewerRequests {
218
201
  videoSpeedX: processing.videoSpeedX ?? null,
219
202
  audioSpeedX: processing.audioSpeedX ?? null
220
203
  };
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();
204
+ this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
205
+ this.#host.viewerPlaybackState.pruneWaits();
225
206
  }
226
207
 
227
208
  /**
@@ -235,49 +216,14 @@ export class ViewerRequests {
235
216
  * @returns {void}
236
217
  */
237
218
  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;
219
+ const viewer = this.#host.viewers.get(consumerId);
220
+ if (!viewer) return;
221
+ this.#host.viewerPlaybackState.noteFirstFrame({ viewer, generation, waitId, wait, elapsedMs, stages,
222
+ record: observation => this.#host.noteFirstFrame?.({ ...observation, stages }) });
271
223
  }
272
224
 
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;
225
+ #currentViewerToken(consumerId, viewerToken) {
226
+ return viewerToken && this.#host.viewers.get(consumerId) === viewerToken ? viewerToken : null;
281
227
  }
282
228
 
283
229
  #preparedOutputBytes(sourceKey, fileIndex) {
@@ -307,14 +253,6 @@ export class ViewerRequests {
307
253
  return bytes;
308
254
  }
309
255
 
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
256
  /**
319
257
  * Open an output of a file for this viewer and put them on it.
320
258
  *
@@ -548,10 +486,10 @@ export class ViewerRequests {
548
486
  if (!Number.isSafeInteger(generation) || !file || typeof file.sourceKey !== "string" ||
549
487
  !Number.isSafeInteger(file.fileIndex) || file.fileIndex < 0) return;
550
488
  const waitId = `${file.sourceKey}:${file.fileIndex}:${generation}`;
551
- const key = `${consumerId}:${waitId}`;
552
489
  const fileName = file.relativePath ?? file.name ?? "";
553
490
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
554
491
  const viewer = this.#host.viewers.get(consumerId);
492
+ if (!viewer) return;
555
493
  const link = linkMeasurementOf(viewer);
556
494
  const features = {
557
495
  extension,
@@ -570,10 +508,9 @@ export class ViewerRequests {
570
508
  };
571
509
  // A seek is a new observation group even when a caller repeats the same
572
510
  // 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);
511
+ this.#host.viewerPlaybackState.deleteWait(viewer, waitId);
512
+ this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey: file.sourceKey,
513
+ fileIndex: file.fileIndex, waitId, features });
577
514
  }
578
515
 
579
516
  /**
@@ -690,6 +627,8 @@ export class ViewerRequests {
690
627
  // without this the base session would idle out from under its own variants.
691
628
  const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
692
629
  this.#host.outputs.touch(session);
630
+ const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
631
+ const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
693
632
  const audio = this.#host.renditions.servesAudioSeparately(session)
694
633
  ? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
695
634
  const failed = [session, audio].find(output => output && this.#host.encodeRuns.wireStateOf(output) === "failed");
@@ -709,8 +648,11 @@ export class ViewerRequests {
709
648
  // Observed output bitrate (Mbit/s) from recently completed segments. The
710
649
  // proxy uses it for the viewer-link budget and the readiness forecast.
711
650
  const outputMbps = await this.#host.quality.observedStreamMbps(session);
651
+ if (!stillOwnsProgress()) return null;
712
652
  const progress = this.#host.encodeRuns.progressOf(session);
713
- const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps);
653
+ const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
654
+ viewer, stillOwnsProgress);
655
+ if (!stillOwnsProgress()) return null;
714
656
  return {
715
657
  // The id the caller asked about, not the variant it was answered from —
716
658
  // the browser tracks its sessions by the id it was given.
@@ -795,7 +737,16 @@ export class ViewerRequests {
795
737
  };
796
738
  }
797
739
 
798
- async #playbackReadinessFor(session, consumerId, progress, outputMbps) {
740
+ #stillOwnsProgress(named, session, consumerId, viewer) {
741
+ if (this.#host.outputs.get(named.id) !== named || this.#host.outputs.get(session.id) !== session) return false;
742
+ if (!viewer) return !consumerId || !this.#host.viewers.get(consumerId);
743
+ return this.#host.viewers.get(consumerId) === viewer &&
744
+ (!(viewer.outputs instanceof Set) || viewer.outputs.has(named.id)) &&
745
+ this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs }) === session;
746
+ }
747
+
748
+ async #playbackReadinessFor(session, consumerId, progress, outputMbps, viewer, stillOwnsProgress) {
749
+ if (!stillOwnsProgress()) return null;
799
750
  const waitStartedAt = performance.now();
800
751
  const waitFields = { process: "P14", sessionId: session.id, consumerId };
801
752
  this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
@@ -806,12 +757,13 @@ export class ViewerRequests {
806
757
  // held viewer's answer took 11.2 s while its forecast took 0.1-0.4 s and
807
758
  // the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
808
759
  const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
809
- const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
810
760
  const viewerReading = viewer?.linkReading() ?? null;
811
- const measurement = this.#rateStateFor(session, consumerId);
761
+ const measurement = viewer
762
+ ? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
763
+ : { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
812
764
  const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
813
765
  if (Number.isFinite(viewerReading?.linkSampleMbps) && viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
814
- measurement.link.add(linkAt, viewerReading.linkSampleMbps * 1_000_000);
766
+ this.#host.viewerPlaybackState.addLinkReading(measurement, linkAt, viewerReading.linkSampleMbps);
815
767
  }
816
768
 
817
769
  const outputs = [session];
@@ -828,6 +780,7 @@ export class ViewerRequests {
828
780
  let primarySourceStats = null;
829
781
  const tracks = [];
830
782
  for (const output of outputs) {
783
+ if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
831
784
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
832
785
  const trackRates = this.#trackRateReadings(output, now);
833
786
  const trackName = output === session ? "video" : "audio";
@@ -840,7 +793,7 @@ export class ViewerRequests {
840
793
  const placedByPlayer = Boolean(servedInit?.length);
841
794
  const sessionInit = placedByPlayer ? servedInit : null;
842
795
  const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
843
- const segments = this.#segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
796
+ const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
844
797
  ? viewerReading.positionSeconds : this.#host.viewerSecondsOn(session, consumerId, now));
845
798
  const sourceIndexes = [...output.spec.sourceFileIndexes];
846
799
  if (sourceIndexes.length === 0) {
@@ -861,6 +814,7 @@ export class ViewerRequests {
861
814
  } catch {
862
815
  stats = null;
863
816
  }
817
+ if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
864
818
  if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
865
819
  parts.statsMs += performance.now() - statsAt;
866
820
  parts.workerForecastMs += Number(stats?.forecastMs) || 0;
@@ -882,9 +836,14 @@ export class ViewerRequests {
882
836
  Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
883
837
  ? stats.fileLength / session.file.durationSeconds : null,
884
838
  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),
839
+ downloadRateReadings: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
840
+ ? this.#host.viewerPlaybackState.sourceRateReadings(measurement, output.file.sourceKey, stats, now,
841
+ this.#host.playbackReadiness, this.#host.lookaheadSeconds) : null,
842
+ tailRateReadings: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
843
+ ? this.#host.viewerPlaybackState.sourceTailRateReadings(measurement, output.file.sourceKey, now,
844
+ this.#host.playbackReadiness) : null,
845
+ downloadRateSummaries: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
846
+ ? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null,
888
847
  downloadForecast,
889
848
  });
890
849
  }
@@ -893,6 +852,7 @@ export class ViewerRequests {
893
852
  const observedMbps = output === session
894
853
  ? outputMbps
895
854
  : await this.#host.quality.observedStreamMbps(output);
855
+ if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
896
856
  parts.qualityMs += performance.now() - qualityAt;
897
857
  const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
898
858
  const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
@@ -936,12 +896,14 @@ export class ViewerRequests {
936
896
  segmentCount: output.timeline?.segmentCount ?? segments.length,
937
897
  readings: trackRates,
938
898
  segments,
939
- geometry: this.#forecastPieces.get(output)?.geometry,
899
+ geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
940
900
  readySegmentIndices: [...inventory.keys()],
941
901
  segmentSizesBytes: inventory
942
902
  });
943
903
  }
944
904
 
905
+ if (!stillOwnsProgress()) return null;
906
+
945
907
  const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
946
908
  const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
947
909
  ? viewerReading.positionSeconds
@@ -991,7 +953,7 @@ export class ViewerRequests {
991
953
  this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
992
954
  }
993
955
  const predictMs = performance.now() - startedAt;
994
- this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
956
+ this.#host.noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
995
957
  logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
996
958
  `(the torrent thread's arrival forecast ${Math.round(parts.workerForecastMs)}ms), stream rate ${Math.round(parts.qualityMs)}ms, ` +
997
959
  `start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
@@ -1018,7 +980,7 @@ export class ViewerRequests {
1018
980
  ? this.#host.generationOfRequest(consumerId) : null;
1019
981
  const earlyWaitId = Number.isSafeInteger(generation)
1020
982
  ? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
1021
- if (earlyWaitId && this.#waitObservations.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
983
+ if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
1022
984
  const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
1023
985
  const videoOperation = forecast.operations.find(operation => operation.track === "video");
1024
986
  const audioOperation = forecast.operations.find(operation => operation.track === "audio");
@@ -1026,6 +988,7 @@ export class ViewerRequests {
1026
988
  sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
1027
989
  fileName: session.file.relativePath ?? session.file.name ?? "",
1028
990
  fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
991
+ viewerToken: viewer,
1029
992
  linkMbps: viewerReading?.linkMbps,
1030
993
  linkSampleAgeMs: Number.isFinite(linkMeasuredAt) ? Math.max(0, Date.now() - linkMeasuredAt) : undefined,
1031
994
  processing: {
@@ -1163,85 +1126,6 @@ export class ViewerRequests {
1163
1126
  * @param {number} positionSeconds
1164
1127
  * @returns {object[]}
1165
1128
  */
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
1129
  /**
1246
1130
  * The encoder's rate for the forecast: the output's whole window of readings,
1247
1131
  * summarized again on each poll. A run that ended between two polls took its
@@ -1270,93 +1154,6 @@ export class ViewerRequests {
1270
1154
  return null;
1271
1155
  }
1272
1156
 
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
1157
  }
1361
1158
 
1362
1159
  /**
@@ -1399,8 +1196,3 @@ function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
1399
1196
  * @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
1400
1197
  * @returns {object | null}
1401
1198
  */
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
- }
@@ -107,6 +107,9 @@ import { describeProcessor, processorBetween, sampleProcessor } from "../storage
107
107
  import { ProcessorBudget } from "../encode/ProcessorBudget.js";
108
108
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
109
109
  import { secondsUntilClosed } from "../encode/piece-close-time.js";
110
+ import { ViewerPlaybackState } from "../viewer/ViewerPlaybackState.js";
111
+ import { OutputForecastGeometry } from "../viewer/OutputForecastGeometry.js";
112
+ import { ForecastDiagnostics } from "./ForecastDiagnostics.js";
110
113
 
111
114
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
112
115
  // and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
@@ -249,6 +252,14 @@ export function wireOutputs({
249
252
  // output key owns its record, and the graph releases it at the last session.
250
253
  const waits = new WaitLedger();
251
254
  const parts = {};
255
+ parts.viewerPlaybackState = new ViewerPlaybackState();
256
+ parts.outputForecastGeometry = new OutputForecastGeometry({
257
+ lookaheadSeconds: LOOKAHEAD_PAUSE_SECONDS,
258
+ finishedMediaRangesOf: (output, index) => parts.encodeRuns.finishedMediaRangesOf(output, index),
259
+ sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
260
+ endsWithinPlayerSeekTolerance
261
+ });
262
+ parts.forecastDiagnostics = new ForecastDiagnostics({ logger });
252
263
  const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
253
264
  sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
254
265
  }) ?? []).find((track) => track.fileIndex === output.spec.audio?.fileIndex &&
@@ -573,6 +584,9 @@ export function wireOutputs({
573
584
  planEncodersSoon: () => parts.encodeRuns.planEncodersSoon(),
574
585
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
575
586
  clearWaits: () => waits.clear(),
587
+ forgetForecastOutput: (output) => parts.viewerRequests.outputDisposed(output),
588
+ viewerDeparted: (viewer) => parts.viewerRequests.viewerDeparted(viewer),
589
+ outputViewerDeparted: (viewer, output) => parts.viewerRequests.outputViewerDeparted(viewer, output),
576
590
  segmentFormatOfKey,
577
591
  // Where the viewers of one output stand, in its segments: what the store
578
592
  // the output retention policy reads to give disk back in the right order.
@@ -632,6 +646,9 @@ export function wireOutputs({
632
646
  // What a viewer asks for: an output of a file, a position, a report of progress.
633
647
  parts.viewerRequests = new ViewerRequests({
634
648
  observeWait,
649
+ viewerPlaybackState: parts.viewerPlaybackState,
650
+ outputForecastGeometry: parts.outputForecastGeometry,
651
+ noteForecast: (...args) => parts.forecastDiagnostics.note(...args),
635
652
  // Whether this machine has a place for one more viewer on this output —
636
653
  // asked in the same synchronous stretch as they are put on it.
637
654
  admitsWatching: (output) => parts.admission.admitsWatching(output?.outputKey ?? ""),