@torrent-tv/proxy 2.107.53 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.107.53",
3
+ "version": "2.107.54",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -70,12 +70,10 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
70
70
  // local lookup. Without the await the reply was the pending promise itself,
71
71
  // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
72
72
  const statsStartedAt = performance.now();
73
- let statsCompletedAt;
74
73
  let stats;
75
74
  try {
76
75
  stats = await torrentPool.getFileStats(torrent, fileIndex);
77
76
  } finally {
78
- statsCompletedAt = performance.now();
79
77
  logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
80
78
  }
81
79
  if (stats?.pending || !stats) {
@@ -161,9 +159,11 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
161
159
  }
162
160
  const responsePreparedAtProxyMs = performance.now();
163
161
  return reply.send({ ...stats, sourceSnapshotTransport: {
162
+ clockEpoch: performance.timeOrigin,
163
+ snapshotClockEpoch: stats.sourceCapture?.clockEpoch ?? null,
164
164
  requestReceivedAtProxyMs,
165
- snapshotStartedAtProxyMs: statsStartedAt,
166
- snapshotCompletedAtProxyMs: statsCompletedAt,
165
+ snapshotStartedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.startedAt : null,
166
+ snapshotCompletedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.completedAt : null,
167
167
  responsePreparedAtProxyMs
168
168
  } });
169
169
  }
@@ -117,12 +117,30 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
117
117
  linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
118
118
  linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
119
119
  linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
120
+ linkSampleAgeMs: Number.isFinite(body.linkSampleAgeMs) && body.linkSampleAgeMs >= 0
121
+ ? body.linkSampleAgeMs : undefined,
120
122
  appendSessionId: sessionId,
121
123
  appendEpoch: typeof body.appendEpoch === "string" ? body.appendEpoch : undefined,
122
124
  appendEpochStartedAt: Number.isFinite(body.appendEpochStartedAt) && body.appendEpochStartedAt > 0
123
125
  ? body.appendEpochStartedAt : undefined,
124
126
  appendSnapshotAt: Number.isFinite(body.appendSnapshotAt) && body.appendSnapshotAt > 0
125
127
  ? body.appendSnapshotAt : undefined,
128
+ captureClockEpoch: Number.isFinite(body.captureClockEpoch) ? body.captureClockEpoch : undefined,
129
+ captureEpoch: typeof body.captureEpoch === "string" ? body.captureEpoch : undefined,
130
+ captureEpochStartedAtBrowser: Number.isFinite(body.captureEpochStartedAtBrowser)
131
+ ? body.captureEpochStartedAtBrowser : undefined,
132
+ captureRevision: Number.isSafeInteger(body.captureRevision) ? body.captureRevision : undefined,
133
+ captureStartedAtBrowser: Number.isFinite(body.captureStartedAtBrowser) ? body.captureStartedAtBrowser : undefined,
134
+ captureCompletedAtBrowser: Number.isFinite(body.captureCompletedAtBrowser)
135
+ ? body.captureCompletedAtBrowser : undefined,
136
+ linkSampleStartedAtBrowser: Number.isFinite(body.linkSampleStartedAtBrowser)
137
+ ? body.linkSampleStartedAtBrowser : undefined,
138
+ linkSampleCompletedAtBrowser: Number.isFinite(body.linkSampleCompletedAtBrowser)
139
+ ? body.linkSampleCompletedAtBrowser : undefined,
140
+ linkSampleActionId: typeof body.linkSampleActionId === "string" ? body.linkSampleActionId : undefined,
141
+ linkSampleGeneration: Number.isSafeInteger(body.linkSampleGeneration) ? body.linkSampleGeneration : undefined,
142
+ linkSampleClockEpoch: Number.isFinite(body.linkSampleClockEpoch) ? body.linkSampleClockEpoch : undefined,
143
+ linkSampleRevision: Number.isSafeInteger(body.linkSampleRevision) ? body.linkSampleRevision : undefined,
126
144
  appendService: body.appendService && typeof body.appendService === "object" ? body.appendService : undefined,
127
145
  fragmentService: body.fragmentService && typeof body.fragmentService === "object" ? body.fragmentService : undefined,
128
146
  bufferedAheadSec,
@@ -19,13 +19,21 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewer
19
19
  // quality change the stream on screen is another session — a different one
20
20
  // for each viewer who changed.
21
21
  const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
22
- const progress = await viewerRequests.getSessionProgress(sessionId, consumerId);
22
+ const stated = Number(req.query?.generation);
23
+ const generation = Number.isSafeInteger(stated) && stated >= 0 ? stated : Number.NaN;
24
+ const progress = await viewerRequests.getSessionProgress(sessionId, consumerId, generation);
25
+ if (progress?.superseded === true) {
26
+ return reply.code(409).send({ error: "Viewer action was superseded." });
27
+ }
23
28
  if (!progress) {
24
29
  return reply.code(404).send({ error: "Transcode session was not found." });
25
30
  }
26
31
 
27
32
  const responsePreparedAtProxyMs = performance.now();
28
33
  progress.snapshotTransport = {
34
+ clockEpoch: performance.timeOrigin,
35
+ snapshotClockEpoch: progress.playbackReadiness?.snapshotClockEpoch ?? null,
36
+ generation: Number.isSafeInteger(generation) ? generation : null,
29
37
  requestReceivedAtProxyMs,
30
38
  snapshotAtProxyMs: progress.playbackReadiness?.snapshotAtProxyMs ?? null,
31
39
  responsePreparedAtProxyMs
@@ -37,6 +37,8 @@
37
37
  * owns the output. This carries a decision out and reports what happened.
38
38
  */
39
39
 
40
+ import { performance } from "node:perf_hooks";
41
+
40
42
  import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
41
43
  import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
42
44
  import { workDeltaBetween } from "./encoder-readings.js";
@@ -228,6 +230,7 @@ export class EncodeRun {
228
230
  * @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
229
231
  * @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
230
232
  * @param {() => number} [params.now]
233
+ * @param {() => number} [params.monotonicNow]
231
234
  * @param {(ended: RunEnded) => void} [params.onEnded]
232
235
  * @param {(reading: { speed: number, at: number, workSeconds: number, workingSeconds: number }) => void} [params.onSpeedMeasured] -
233
236
  * Told each time this run has measured its processing speed, once per
@@ -291,6 +294,7 @@ export class EncodeRun {
291
294
  indexOfName,
292
295
  startSeconds = 0,
293
296
  totalSeconds = null,
297
+ monotonicNow,
294
298
  because = "no reason was given"
295
299
  }) {
296
300
  this.address = address;
@@ -302,10 +306,11 @@ export class EncodeRun {
302
306
  this.spawnProcess = spawn;
303
307
  this.logger = logger;
304
308
  this.now = typeof now === "function" ? now : Date.now;
305
- this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now });
309
+ this.monotonicNow = typeof monotonicNow === "function" ? monotonicNow : () => performance.now();
310
+ this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now, monotonicNow: this.monotonicNow });
306
311
  /** Where this run's time went: its own work, or waiting, or stopped. */
307
312
  this.clock = new RunClock({ now: this.now });
308
- this.#pieceSample = { at: this.now(), producedSeconds: 0, workingMs: 0 };
313
+ this.#pieceSample = { at: this.now(), monotonicAt: this.monotonicNow(), producedSeconds: 0, workingMs: 0 };
309
314
  this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
310
315
  this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
311
316
  this.onProduced = typeof onProduced === "function" ? onProduced : () => {};
@@ -463,6 +468,7 @@ export class EncodeRun {
463
468
  this.#pieceSample = {
464
469
  ...this.#workSample,
465
470
  at: this.now(),
471
+ monotonicAt: this.monotonicNow(),
466
472
  workingMs: this.clock.workingMs()
467
473
  };
468
474
  }
@@ -600,6 +606,7 @@ export class EncodeRun {
600
606
  const at = this.now();
601
607
  this.#workSample = {
602
608
  at,
609
+ monotonicAt: this.progress.updatedAtMonotonic,
603
610
  producedSeconds: Math.max(0, this.progress.processedSeconds - this.progress.startPositionSeconds),
604
611
  workingMs: this.clock.workingMs(at)
605
612
  };
@@ -766,7 +773,10 @@ export class EncodeRun {
766
773
  }
767
774
  const work = workDeltaBetween(this.#pieceSample, sample);
768
775
  if (work !== null) {
769
- this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work };
776
+ this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work,
777
+ captureOwner: "proxy-encoder", captureClock: "performance", captureClockEpoch: performance.timeOrigin,
778
+ captureStartedAtMonotonic: this.#pieceSample.monotonicAt,
779
+ captureCompletedAtMonotonic: sample.monotonicAt };
770
780
  this.#pieceSample = sample;
771
781
  this.#speedMeasurementPending = false;
772
782
  this.onSpeedMeasured(this.speedReading);
@@ -1,3 +1,5 @@
1
+ import { performance } from "node:perf_hooks";
2
+
1
3
  /**
2
4
  * @file Progress reported by one encoder run, placed on the source timeline.
3
5
  *
@@ -22,15 +24,18 @@ function parseTimestamp(value) {
22
24
  }
23
25
 
24
26
  export class RunProgress {
25
- constructor({ startSeconds = 0, totalSeconds = null, now = Date.now } = {}) {
27
+ constructor({ startSeconds = 0, totalSeconds = null, now = Date.now, monotonicNow = () => performance.now() } = {}) {
26
28
  this.startPositionSeconds = Number.isFinite(startSeconds) ? Math.max(0, startSeconds) : 0;
27
29
  this.processedSeconds = this.startPositionSeconds;
28
30
  this.totalSeconds = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : null;
29
31
  this.percent = null;
30
32
  this.remainingSeconds = this.totalSeconds;
31
33
  this.updatedAt = now();
34
+ this.updatedAtMonotonic = monotonicNow();
35
+ this.clockEpoch = performance.timeOrigin;
32
36
  this.lastLoggedAt = 0;
33
37
  this.now = now;
38
+ this.monotonicNow = monotonicNow;
34
39
  this.#derive();
35
40
  }
36
41
 
@@ -42,6 +47,7 @@ export class RunProgress {
42
47
  this.processedSeconds = this.startPositionSeconds + Math.max(0, relative);
43
48
  }
44
49
  this.updatedAt = this.now();
50
+ this.updatedAtMonotonic = this.monotonicNow();
45
51
  this.#derive();
46
52
  return this.snapshot();
47
53
  }
@@ -61,7 +67,9 @@ export class RunProgress {
61
67
  totalSeconds: this.totalSeconds,
62
68
  percent: this.percent,
63
69
  remainingSeconds: this.remainingSeconds,
64
- updatedAt: this.updatedAt
70
+ updatedAt: this.updatedAt,
71
+ updatedAtMonotonic: this.updatedAtMonotonic,
72
+ clockEpoch: this.clockEpoch
65
73
  };
66
74
  }
67
75
 
@@ -77,3 +85,4 @@ export class RunProgress {
77
85
  this.remainingSeconds = Math.max(0, this.totalSeconds - this.processedSeconds);
78
86
  }
79
87
  }
88
+
@@ -14,7 +14,9 @@
14
14
  */
15
15
 
16
16
  export class SpeedReadings {
17
- /** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number }>>} */
17
+ /** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
18
+ * captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
19
+ * captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number }>>} */
18
20
  #byOutput = new WeakMap();
19
21
  #keepMs;
20
22
 
@@ -25,7 +27,9 @@ export class SpeedReadings {
25
27
 
26
28
  /**
27
29
  * @param {object} output
28
- * @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number } | null} reading
30
+ * @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
31
+ * captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
32
+ * captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number } | null} reading
29
33
  */
30
34
  note(output, reading) {
31
35
  if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
@@ -34,6 +38,10 @@ export class SpeedReadings {
34
38
  let place = readings.length;
35
39
  while (place > 0 && readings[place - 1].at > reading.at) place -= 1;
36
40
  const sample = { speed: reading.speed, at: reading.at };
41
+ for (const key of ["captureOwner", "captureClock", "captureClockEpoch", "captureStartedAtMonotonic",
42
+ "captureCompletedAtMonotonic"]) {
43
+ if (reading[key] !== undefined) sample[key] = reading[key];
44
+ }
37
45
  if (Number.isFinite(reading.workSeconds) && reading.workSeconds > 0 &&
38
46
  Number.isFinite(reading.workingSeconds) && reading.workingSeconds > 0) {
39
47
  Object.assign(sample, { workSeconds: reading.workSeconds, workingSeconds: reading.workingSeconds });
@@ -87,6 +87,49 @@ function forecastInputIdentity(state) {
87
87
  return state && JSON.stringify([state.sourceId, state.trackId, state.segmentIndex, state.phase]);
88
88
  }
89
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
+
90
133
  function observeForecastInput(observeWait, sourceKey, state, event) {
91
134
  if (!state) return;
92
135
  observeWait?.(sourceKey, "process", {
@@ -612,7 +655,7 @@ export class ViewerRequests {
612
655
  return this.#host.viewerSecondsOn(session, consumerId);
613
656
  }
614
657
 
615
- async getSessionProgress(sessionId, consumerId = "") {
658
+ async getSessionProgress(sessionId, consumerId = "", statedGeneration = Number.NaN) {
616
659
  if (!isOutputName(sessionId)) {
617
660
  return null;
618
661
  }
@@ -628,6 +671,12 @@ export class ViewerRequests {
628
671
  const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
629
672
  this.#host.outputs.touch(session);
630
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;
631
680
  const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
632
681
  const audio = this.#host.renditions.servesAudioSeparately(session)
633
682
  ? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
@@ -648,10 +697,12 @@ export class ViewerRequests {
648
697
  // Observed output bitrate (Mbit/s) from recently completed segments. The
649
698
  // proxy uses it for the viewer-link budget and the readiness forecast.
650
699
  const outputMbps = await this.#host.quality.observedStreamMbps(session);
700
+ if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
651
701
  if (!stillOwnsProgress()) return null;
652
702
  const progress = this.#host.encodeRuns.progressOf(session);
653
703
  const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
654
704
  viewer, stillOwnsProgress);
705
+ if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
655
706
  if (!stillOwnsProgress()) return null;
656
707
  return {
657
708
  // The id the caller asked about, not the variant it was answered from —
@@ -758,13 +809,31 @@ export class ViewerRequests {
758
809
  // the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
759
810
  const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
760
811
  const viewerReading = viewer?.linkReading() ?? null;
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;
761
818
  const measurement = viewer
762
819
  ? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
763
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
+ }
764
827
  const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
765
- if (Number.isFinite(viewerReading?.linkSampleMbps) && viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
766
- this.#host.viewerPlaybackState.addLinkReading(measurement, linkAt, viewerReading.linkSampleMbps);
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);
767
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;
768
837
 
769
838
  const outputs = [session];
770
839
  const requiresSeparateAudio = this.#host.renditions.servesAudioSeparately(session);
@@ -780,21 +849,24 @@ export class ViewerRequests {
780
849
  let primarySourceStats = null;
781
850
  const tracks = [];
782
851
  for (const output of outputs) {
783
- if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
852
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
784
853
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
785
- const trackRates = this.#trackRateReadings(output, now);
854
+ const trackRate = this.#trackRateReadings(output, now);
855
+ const trackRates = trackRate?.summary ?? null;
786
856
  const trackName = output === session ? "video" : "audio";
787
857
  // The page states the offset its player applied to this track, and the
788
858
  // init it applied it to is the session's. Until both are known, the
789
859
  // pieces stay on their own presentation timeline: there is nothing yet
790
860
  // to place them by.
791
- 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;
792
864
  const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
793
865
  const placedByPlayer = Boolean(servedInit?.length);
794
866
  const sessionInit = placedByPlayer ? servedInit : null;
795
867
  const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
796
- const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
797
- ? 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));
798
870
  const sourceIndexes = [...output.spec.sourceFileIndexes];
799
871
  if (sourceIndexes.length === 0) {
800
872
  sourceIndexes.push(output.file.fileIndex);
@@ -814,36 +886,62 @@ export class ViewerRequests {
814
886
  } catch {
815
887
  stats = null;
816
888
  }
817
- if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
889
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
818
890
  if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
819
891
  parts.statsMs += performance.now() - statsAt;
820
892
  parts.workerForecastMs += Number(stats?.forecastMs) || 0;
893
+ const sourceCapture = sourceCaptureOf(stats, actionId, generation);
821
894
  if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
822
895
  const sharedForecast = sourceForecasts.get(output.file.sourceKey);
823
- 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,
824
902
  ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
825
903
  start: Math.max(0, range.start - stats.fileOffset),
826
904
  end: Math.min(stats.fileLength, range.end - stats.fileOffset)
827
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;
828
928
  sourceMeasurements.set(sourceId, {
829
929
  id: sourceId,
830
930
  serviceKey: output.file.sourceKey,
831
- complete: stats?.fileAvailable === true,
832
- residence: stats?.residence,
833
- fileOffset: stats?.fileOffset,
834
- fileLength: stats?.fileLength,
835
- 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 &&
836
936
  Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
837
937
  ? stats.fileLength / session.file.durationSeconds : null,
838
- pieceLength: stats?.pieceLength,
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,
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 },
847
945
  downloadForecast,
848
946
  });
849
947
  }
@@ -852,36 +950,38 @@ export class ViewerRequests {
852
950
  const observedMbps = output === session
853
951
  ? outputMbps
854
952
  : await this.#host.quality.observedStreamMbps(output);
855
- if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
953
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
856
954
  parts.qualityMs += performance.now() - qualityAt;
857
955
  const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
956
+ const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
957
+ const encoderCaptureValid = encoderCapture.validity === "valid";
858
958
  const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
859
959
  const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
860
960
  tracks.push({
861
961
  id: output.outputKey,
862
962
  clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
863
- clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
864
- viewerReading?.bufferedRanges?.media,
963
+ clientRanges: bufferCaptureValid ? viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
964
+ viewerReading?.bufferedRanges?.media : null,
865
965
  // Browser-observed append and fragment-parse samples are retained for
866
966
  // the full model's service schedule; they are not yet used as numeric
867
967
  // predecessors in the partial continuity calculation.
868
- appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
968
+ appendService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
869
969
  ? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
870
970
  epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
871
971
  receivedAt: viewerReading.appendServiceReceivedAt }
872
972
  : null,
873
- fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
973
+ fragmentService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
874
974
  ? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
875
975
  epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
876
976
  receivedAt: viewerReading.appendServiceReceivedAt }
877
977
  : null,
878
978
  sourceIds,
879
- processedSeconds: trackProgress?.processedSeconds,
880
- startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
979
+ processedSeconds: encoderCaptureValid ? trackProgress?.processedSeconds : null,
980
+ startupRemainingSeconds: !encoderCaptureValid ? null : processingStarted ? 0 : Math.max(0,
881
981
  (this.#host.processingStartupSecondsOf?.(output) ?? 0) - Math.max(0, (now - startedAt) / 1000)),
882
- processingRanges: liveRuns.map(({ progress }) => ({
982
+ processingRanges: encoderCaptureValid ? liveRuns.map(({ progress }) => ({
883
983
  start: progress.startPositionSeconds, end: progress.processedSeconds
884
- })),
984
+ })) : [],
885
985
  // What a run's start costs this output, and the stretches its live
886
986
  // runs were given: the forecast starts a run wherever production meets
887
987
  // bytes that were not held when the run before was admitted.
@@ -894,7 +994,8 @@ export class ViewerRequests {
894
994
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
895
995
  durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
896
996
  segmentCount: output.timeline?.segmentCount ?? segments.length,
897
- readings: trackRates,
997
+ readings: encoderCaptureValid ? trackRates : [],
998
+ capture: encoderCapture,
898
999
  segments,
899
1000
  geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
900
1001
  readySegmentIndices: [...inventory.keys()],
@@ -902,10 +1003,10 @@ export class ViewerRequests {
902
1003
  });
903
1004
  }
904
1005
 
905
- if (!stillOwnsProgress()) return null;
1006
+ if (!stillOwnsProgress() || !actionIsCurrent()) return null;
906
1007
 
907
- const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
908
- const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
1008
+ const bufferedAheadSeconds = bufferCaptureValid ? Number(viewerReading?.bufferedAheadSec) : null;
1009
+ const positionSeconds = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
909
1010
  ? viewerReading.positionSeconds
910
1011
  : this.#host.viewerSecondsOn(session, consumerId, now);
911
1012
  // Both separately delivered tracks can run short independently. Use the
@@ -932,7 +1033,7 @@ export class ViewerRequests {
932
1033
  positionSeconds,
933
1034
  durationSeconds: session.file.durationSeconds,
934
1035
  bufferedAheadSeconds,
935
- bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
1036
+ bufferLimitSeconds: bufferCaptureValid ? viewerReading?.bufferLimitSeconds : null,
936
1037
  reserveSeconds,
937
1038
  lookaheadSeconds: this.#host.lookaheadSeconds,
938
1039
  // Disk room remaining after stock reserved for other active outputs.
@@ -940,7 +1041,7 @@ export class ViewerRequests {
940
1041
  requiredAudio: requiresSeparateAudio,
941
1042
  sources: [...sourceMeasurements.values()],
942
1043
  tracks,
943
- linkReadings: measurement.link.snapshot()
1044
+ linkReadings: viewerReading?.linkCapture?.validity === "valid" ? measurement.link.snapshot() : null
944
1045
  };
945
1046
  // A subtitle track chosen inside THIS file is the last input the forecast
946
1047
  // waits for. A subtitle FILE beside it is named by its own index and is
@@ -960,6 +1061,18 @@ export class ViewerRequests {
960
1061
  const forecast = withSubtitleReadiness(predicted, subtitle,
961
1062
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
962
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
+ };
963
1076
  forecast.operations = outputs.flatMap((output, index) => {
964
1077
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
965
1078
  processedSeconds: tracks[index].processedSeconds,
@@ -976,8 +1089,6 @@ export class ViewerRequests {
976
1089
  outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
977
1090
  ];
978
1091
  });
979
- const generation = consumerId && viewer?.assignments
980
- ? this.#host.generationOfRequest(consumerId) : null;
981
1092
  const earlyWaitId = Number.isSafeInteger(generation)
982
1093
  ? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
983
1094
  if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
@@ -1136,7 +1247,20 @@ export class ViewerRequests {
1136
1247
  #trackRateReadings(output, now) {
1137
1248
  const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
1138
1249
  const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
1139
- 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" })) };
1140
1264
  // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
1141
1265
  // encoder, and it is not kept among the readings. Added on every poll
1142
1266
  // between runs, it put the machine's share of that moment — 1 % at a
@@ -1146,7 +1270,9 @@ export class ViewerRequests {
1146
1270
  if (Number.isFinite(projected) && projected > 0) {
1147
1271
  const seed = new this.#host.playbackReadiness.RateTrend();
1148
1272
  seed.add(now, projected);
1149
- 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 }] };
1150
1276
  }
1151
1277
  // Progress over wall time includes source waits, which the forecast prices
1152
1278
  // separately. Keep the rate unknown until a measured or calibrated
@@ -42,6 +42,9 @@ import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../../storage/memory
42
42
  import { profileThisThread } from "../../storage/cpu-profile.js";
43
43
  import { forwardLogsTo, logger } from "../../../utils/logger.js";
44
44
 
45
+ const WORKER_CLOCK_EPOCH = performance.timeOrigin;
46
+ let sourceSnapshotRevision = 0;
47
+
45
48
  /**
46
49
  * Everything this thread logs goes to the main thread, which owns the file.
47
50
  *
@@ -492,6 +495,8 @@ async function runCommand(command, params, id) {
492
495
 
493
496
  case Command.FILE_STATS: {
494
497
  const statsStartedAt = performance.now();
498
+ const sourceCaptureStartedAt = statsStartedAt;
499
+ const sourceRevision = ++sourceSnapshotRevision;
495
500
  emitWaitTrace(params.sourceKey, "W10", "begin", { startedAt: statsStartedAt,
496
501
  fileIndex: params.fileIndex, forecast: params.forecast === true });
497
502
  const finishStatsTrace = (outcome, fields = {}) => {
@@ -504,7 +509,14 @@ async function runCommand(command, params, id) {
504
509
  if (!torrent) {
505
510
  const whole = wholeSources.fileOf(params.sourceKey, params.fileIndex);
506
511
  finishStatsTrace(whole ? "whole-file" : "pending");
507
- return whole ? wholeFileStats(whole.length) : { pending: true };
512
+ return {
513
+ ...(whole ? wholeFileStats(whole.length) : { pending: true }),
514
+ sourceCapture: {
515
+ owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
516
+ startedAt: sourceCaptureStartedAt, completedAt: performance.now(), revision: sourceRevision,
517
+ validity: whole ? "valid" : "unknown", unknownReason: whole ? null : "source-not-ready"
518
+ }
519
+ };
508
520
  }
509
521
  // What this file's own interruptions demand, measured by the reader in
510
522
  // this thread. It travels with the stats because the caller asking for
@@ -515,7 +527,8 @@ async function runCommand(command, params, id) {
515
527
  const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
516
528
  const forecastAt = performance.now();
517
529
  const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
518
- const forecastMs = params.forecast === true ? performance.now() - forecastAt : 0;
530
+ const forecastCompletedAt = performance.now();
531
+ const forecastMs = params.forecast === true ? forecastCompletedAt - forecastAt : 0;
519
532
  const stats = pool.getFileStats(torrent, params.fileIndex);
520
533
  const ranges = [];
521
534
  if (forecast) for (const [piece, availableAt] of forecast.arrivals) {
@@ -526,8 +539,19 @@ async function runCommand(command, params, id) {
526
539
  finishStatsTrace("ok", { forecast: params.forecast === true });
527
540
  return {
528
541
  ...stats,
542
+ sourceCapture: {
543
+ owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
544
+ startedAt: sourceCaptureStartedAt, completedAt: performance.now(), revision: sourceRevision,
545
+ validity: stats ? "valid" : "unknown", unknownReason: stats ? null : "source-stats-unavailable"
546
+ },
529
547
  ...(params.forecast === true ? { downloadForecast: {
530
- measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges
548
+ measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges,
549
+ capture: {
550
+ owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
551
+ startedAt: forecastAt, completedAt: forecastCompletedAt, revision: sourceRevision,
552
+ validity: forecast ? "valid" : "unknown",
553
+ unknownReason: forecast ? null : "source-arrival-forecast-unavailable"
554
+ }
531
555
  } } : {}),
532
556
  supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4),
533
557
  // How long this answer waited for the arrival forecast, which the
@@ -53,6 +53,34 @@ function fragmentEventAges(value) {
53
53
  Number.isFinite(value?.[name]) && value[name] >= 0 ? value[name] : null]));
54
54
  }
55
55
 
56
+ function browserCaptureOf(report, sessionId) {
57
+ const clockEpoch = report.captureClockEpoch;
58
+ const startedAt = report.captureStartedAtBrowser;
59
+ const completedAt = report.captureCompletedAtBrowser;
60
+ const valid = typeof report.captureEpoch === "string" && report.captureEpoch.length > 0 &&
61
+ Number.isFinite(report.captureEpochStartedAtBrowser) && Number.isFinite(report.captureRevision) &&
62
+ Number.isSafeInteger(report.captureRevision) && report.captureRevision > 0 &&
63
+ Number.isFinite(clockEpoch) && Number.isFinite(startedAt) && Number.isFinite(completedAt) &&
64
+ report.captureEpochStartedAtBrowser >= clockEpoch && startedAt >= report.captureEpochStartedAtBrowser &&
65
+ completedAt >= startedAt &&
66
+ Number.isSafeInteger(report.generation) && report.generation >= 0;
67
+ return {
68
+ owner: "browser",
69
+ clock: "performance",
70
+ clockEpoch: Number.isFinite(clockEpoch) ? clockEpoch : null,
71
+ startedAt: Number.isFinite(startedAt) ? startedAt : null,
72
+ completedAt: Number.isFinite(completedAt) ? completedAt : null,
73
+ actionId: typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null,
74
+ generation: Number.isSafeInteger(report.generation) ? report.generation : null,
75
+ revision: Number.isSafeInteger(report.captureRevision) ? report.captureRevision : null,
76
+ epoch: typeof report.captureEpoch === "string" ? report.captureEpoch : null,
77
+ epochStartedAt: Number.isFinite(report.captureEpochStartedAtBrowser)
78
+ ? report.captureEpochStartedAtBrowser : null,
79
+ validity: valid ? "valid" : "unknown",
80
+ unknownReason: valid ? null : "missing-or-invalid-browser-capture"
81
+ };
82
+ }
83
+
56
84
  function appendServiceSnapshot(value) {
57
85
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
58
86
  const snapshot = {};
@@ -522,6 +550,18 @@ export class Viewer {
522
550
  appendEpoch = null,
523
551
  appendEpochStartedAt = null,
524
552
  appendSnapshotAt = null,
553
+ captureClockEpoch = null,
554
+ captureEpoch = null,
555
+ captureEpochStartedAtBrowser = null,
556
+ captureRevision = null,
557
+ captureStartedAtBrowser = null,
558
+ captureCompletedAtBrowser = null,
559
+ linkSampleStartedAtBrowser = null,
560
+ linkSampleCompletedAtBrowser = null,
561
+ linkSampleActionId = null,
562
+ linkSampleGeneration = null,
563
+ linkSampleClockEpoch = null,
564
+ linkSampleRevision = null,
525
565
  appendService: appendServiceReading = null,
526
566
  fragmentService: fragmentServiceReading = null,
527
567
  bufferedAheadSec,
@@ -564,6 +604,18 @@ export class Viewer {
564
604
  const sampleMbps = Number(linkSampleMbps);
565
605
  const sampleAt = Number(linkSampleAt);
566
606
  const previous = this.netReport;
607
+ const browserCapture = browserCaptureOf({ captureClockEpoch, captureEpoch, captureEpochStartedAtBrowser,
608
+ captureRevision, captureStartedAtBrowser, captureCompletedAtBrowser, generation }, appendSessionId);
609
+ const previousCapture = previous?.bufferCapture ?? null;
610
+ const captureOrderKnown = browserCapture.clockEpoch !== null && browserCapture.epoch !== null &&
611
+ browserCapture.epochStartedAt !== null && browserCapture.revision !== null && browserCapture.revision > 0;
612
+ const sameCaptureClock = captureOrderKnown && browserCapture.clockEpoch === previousCapture?.clockEpoch;
613
+ const sameCaptureEpoch = sameCaptureClock && browserCapture.epoch === previousCapture?.epoch;
614
+ const newerCapture = captureOrderKnown && (!previousCapture || !previousCapture.clockEpoch ||
615
+ !sameCaptureClock && browserCapture.epochStartedAt > (previousCapture.epochStartedAt ?? 0) ||
616
+ sameCaptureClock && (sameCaptureEpoch && browserCapture.revision > previousCapture.revision ||
617
+ !sameCaptureEpoch && browserCapture.epochStartedAt > (previousCapture.epochStartedAt ?? 0)));
618
+ const staleCapture = captureOrderKnown && previousCapture && !newerCapture;
567
619
  const incomingAppendService = typeof appendEpoch === "string" && appendEpoch.length > 0
568
620
  ? appendServiceSnapshot(appendServiceReading) : null;
569
621
  const incomingFragmentService = typeof appendEpoch === "string" && appendEpoch.length > 0
@@ -578,37 +630,85 @@ export class Viewer {
578
630
  (!previous?.appendService || appendServiceIsMonotonic(incomingAppendService, previous.appendService)) &&
579
631
  (!previous?.fragmentService || fragmentServiceIsMonotonic(incomingFragmentService, previous.fragmentService))
580
632
  : appendEpochStartedAt > (previous?.appendEpochStartedAt ?? 0));
633
+ const preserveBrowserService = sameAppendEpoch && incomingAppendService && incomingFragmentService;
581
634
  const hasPlaybackSample = Number.isFinite(sampleMbps) && sampleMbps > 0 &&
582
- Number.isFinite(sampleAt) && sampleAt > 0;
583
- const newerPlaybackSample = hasPlaybackSample && sampleAt > (this.netReport?.linkSampleAt ?? 0);
635
+ Number.isFinite(sampleAt) && sampleAt > 0 && Number.isFinite(captureClockEpoch) &&
636
+ Number.isFinite(linkSampleClockEpoch) && linkSampleClockEpoch === captureClockEpoch &&
637
+ Number.isSafeInteger(linkSampleRevision) && linkSampleRevision > 0 &&
638
+ Number.isFinite(linkSampleStartedAtBrowser) && Number.isFinite(linkSampleCompletedAtBrowser) &&
639
+ linkSampleStartedAtBrowser >= captureClockEpoch && linkSampleCompletedAtBrowser === sampleAt &&
640
+ linkSampleStartedAtBrowser <= linkSampleCompletedAtBrowser &&
641
+ Number.isFinite(captureCompletedAtBrowser) && sampleAt <= captureCompletedAtBrowser &&
642
+ Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0;
643
+ const oldLinkEpoch = this.netReport?.linkCapture?.epoch !== String(linkSampleClockEpoch) &&
644
+ Number.isFinite(this.netReport?.linkCapture?.epochStartedAt) &&
645
+ Number.isFinite(linkSampleClockEpoch) &&
646
+ linkSampleClockEpoch <= this.netReport.linkCapture.epochStartedAt;
647
+ const newerPlaybackSample = hasPlaybackSample && !oldLinkEpoch &&
648
+ (linkSampleClockEpoch !== this.netReport?.linkCapture?.clockEpoch ||
649
+ linkSampleRevision > (this.netReport?.linkCapture?.revision ?? 0));
650
+ const linkClockEpochChanged = Number.isFinite(captureClockEpoch) &&
651
+ Number.isFinite(this.netReport?.linkCapture?.clockEpoch) &&
652
+ captureClockEpoch !== this.netReport.linkCapture.clockEpoch;
653
+ const newerInvalidPlaybackSample = !stale && newerCapture && !hasPlaybackSample &&
654
+ (Number.isSafeInteger(linkSampleRevision) || linkSampleAt !== null && linkSampleAt !== undefined ||
655
+ linkClockEpochChanged);
656
+ const preserveCapture = stale || staleCapture;
657
+ const linkCapture = newerPlaybackSample ? {
658
+ owner: "browser", clock: "performance", clockEpoch: linkSampleClockEpoch,
659
+ startedAt: linkSampleStartedAtBrowser, completedAt: linkSampleCompletedAtBrowser,
660
+ actionId: typeof linkSampleActionId === "string" ? linkSampleActionId : null,
661
+ generation: Number.isSafeInteger(linkSampleGeneration) ? linkSampleGeneration : null,
662
+ epoch: String(linkSampleClockEpoch), epochStartedAt: linkSampleClockEpoch,
663
+ revision: linkSampleRevision, validity: "valid", unknownReason: null
664
+ } : newerInvalidPlaybackSample ? {
665
+ owner: "browser", clock: "performance", clockEpoch: Number.isFinite(captureClockEpoch) ? captureClockEpoch : null,
666
+ startedAt: null, completedAt: null, actionId: appendSessionId,
667
+ generation: Number.isSafeInteger(generation) ? generation : null,
668
+ epoch: Number.isFinite(captureClockEpoch) ? String(captureClockEpoch) : null,
669
+ epochStartedAt: captureClockEpoch,
670
+ revision: linkSampleRevision, validity: "invalid", unknownReason: "invalid-link-sample-capture"
671
+ } : this.netReport?.linkCapture ?? {
672
+ owner: "browser", clock: "performance", clockEpoch: null, startedAt: null, completedAt: null,
673
+ actionId: appendSessionId, generation: Number.isSafeInteger(generation) ? generation : null,
674
+ epoch: null, epochStartedAt: null, revision: null, validity: "unknown",
675
+ unknownReason: "missing-link-sample-capture"
676
+ };
677
+ const useCapture = !stale && !staleCapture && newerCapture && browserCapture.validity === "valid";
584
678
  this.netReport = {
585
679
  linkMbps: lastLinkMbps,
586
680
  linkMeasuredAt,
587
- linkSampleAt: newerPlaybackSample ? sampleAt : this.netReport?.linkSampleAt ?? null,
588
- linkSampleMbps: newerPlaybackSample ? sampleMbps : this.netReport?.linkSampleMbps ?? null,
589
- linkSampleMeasuredAt: newerPlaybackSample
590
- ? Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0
591
- ? now - linkSampleAgeMs
592
- : now
593
- : this.netReport?.linkSampleMeasuredAt ?? null,
681
+ linkSampleAt: newerPlaybackSample ? sampleAt : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleAt ?? null,
682
+ linkSampleMbps: newerPlaybackSample ? sampleMbps : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleMbps ?? null,
683
+ linkSampleMeasuredAt: newerPlaybackSample ? now : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleMeasuredAt ?? null,
684
+ linkCapture,
685
+ bufferCapture: preserveCapture ? previousCapture ?? browserCapture : useCapture ? browserCapture : {
686
+ ...browserCapture,
687
+ validity: "unknown",
688
+ unknownReason: browserCapture.unknownReason ?? "missing-or-invalid-browser-capture"
689
+ },
594
690
  appendSessionId: useBrowserService ? appendSessionId : previous?.appendSessionId ?? null,
595
- appendEpoch: useBrowserService ? appendEpoch : previous?.appendEpoch ?? null,
691
+ appendEpoch: useBrowserService ? appendEpoch : preserveBrowserService ? previous?.appendEpoch ?? null
692
+ : useCapture ? null : previous?.appendEpoch ?? null,
596
693
  appendEpochStartedAt: useBrowserService ? appendEpochStartedAt : previous?.appendEpochStartedAt ?? null,
597
694
  appendSnapshotAt: useBrowserService ? appendSnapshotAt : previous?.appendSnapshotAt ?? null,
598
- appendService: useBrowserService ? incomingAppendService : previous?.appendService ?? null,
599
- fragmentService: useBrowserService ? incomingFragmentService : previous?.fragmentService ?? null,
600
- appendServiceReceivedAt: useBrowserService ? now : previous?.appendServiceReceivedAt ?? null,
601
- bufferedAheadSec: held,
695
+ appendService: useBrowserService ? incomingAppendService : preserveBrowserService ? previous?.appendService ?? null
696
+ : useCapture ? null : previous?.appendService ?? null,
697
+ fragmentService: useBrowserService ? incomingFragmentService : preserveBrowserService ? previous?.fragmentService ?? null
698
+ : useCapture ? null : previous?.fragmentService ?? null,
699
+ appendServiceReceivedAt: useBrowserService ? now : preserveBrowserService ? previous?.appendServiceReceivedAt ?? null
700
+ : useCapture ? null : previous?.appendServiceReceivedAt ?? null,
701
+ bufferedAheadSec: preserveCapture ? previous?.bufferedAheadSec ?? 0 : useCapture ? held : 0,
602
702
  timestampOffsets: Object.fromEntries(["video", "audio"].filter((key) =>
603
703
  Number.isFinite(timestampOffsets?.[key])).map((key) => [key, timestampOffsets[key]])),
604
- bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
704
+ bufferedRanges: preserveCapture ? previous?.bufferedRanges ?? null : useCapture && bufferedRanges && typeof bufferedRanges === "object" ?
605
705
  Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
606
706
  .map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
607
707
  Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.start >= 0 && range.end > range.start)
608
708
  .map(({ start, end }) => ({ start, end }))])) : null,
609
- bufferLimitSeconds: Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
709
+ bufferLimitSeconds: preserveCapture ? previous?.bufferLimitSeconds ?? null : useCapture && Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
610
710
  ? bufferLimitSeconds
611
- : this.netReport?.bufferLimitSeconds ?? null,
711
+ : useCapture ? null : this.netReport?.bufferLimitSeconds ?? null,
612
712
  positionSeconds:
613
713
  Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
614
714
  at: now
@@ -46,11 +46,15 @@ export class ViewerPlaybackState {
46
46
  return removed;
47
47
  }
48
48
 
49
- addLinkReading(state, at, mbps) {
50
- state.link.add(at, mbps * 1_000_000);
49
+ addLinkReading(state, at, mbps, capture = null) {
50
+ state.link.add(at, mbps * 1_000_000, capture);
51
51
  }
52
52
 
53
- sourceRateReadings(state, id, stats, now, playbackReadiness, lookaheadSeconds) {
53
+ forgetLinkReadings(state, playbackReadiness, lookaheadSeconds) {
54
+ if (state) state.link = new playbackReadiness.RateTrend(lookaheadSeconds);
55
+ }
56
+
57
+ sourceRateReadings(state, id, stats, now, playbackReadiness, lookaheadSeconds, capture = null) {
54
58
  let source = state.sources.get(id);
55
59
  if (!source) {
56
60
  const { RateTrend, SessionRate } = playbackReadiness;
@@ -58,15 +62,16 @@ export class ViewerPlaybackState {
58
62
  deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
59
63
  state.sources.set(id, source);
60
64
  }
65
+ const readingCapture = capture ?? stats?.sourceCapture ?? null;
61
66
  if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
62
- source.delivered.add(now, stats.downloadSpeed);
63
- source.deliveredAll.add(now, stats.downloadSpeed);
67
+ source.delivered.add(now, stats.downloadSpeed, readingCapture);
68
+ source.deliveredAll.add(now, stats.downloadSpeed, readingCapture);
64
69
  }
65
70
  const limit = stats?.selectionLimit;
66
71
  if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
67
72
  const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
68
- source.free.add(now, free);
69
- source.freeAll.add(now, free);
73
+ source.free.add(now, free, readingCapture);
74
+ source.freeAll.add(now, free, readingCapture);
70
75
  }
71
76
  if (limit) source.limit = limit;
72
77
  return sourceService(source.free.snapshot(), source.delivered.snapshot(),
@@ -85,6 +90,19 @@ export class ViewerPlaybackState {
85
90
  return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
86
91
  }
87
92
 
93
+ sourceCaptureSnapshot(state, id) {
94
+ const source = state.sources.get(id);
95
+ if (!source) return null;
96
+ return {
97
+ recent: { delivered: source.delivered.captureSnapshot(), free: source.free.captureSnapshot() },
98
+ tail: { delivered: source.deliveredAll.captureSnapshot(), free: source.freeAll.captureSnapshot() }
99
+ };
100
+ }
101
+
102
+ forgetSourceReadings(state, id) {
103
+ state?.sources.delete(id);
104
+ }
105
+
88
106
  getWait(viewer, waitId) {
89
107
  return this.#stateFor(viewer)?.waits.get(waitId) ?? null;
90
108
  }
@@ -149,14 +149,16 @@ export class SessionRate {
149
149
  #inverseSum = 0;
150
150
  #zero = false;
151
151
  #weighted = 0;
152
+ #capture = null;
152
153
 
153
- add(at, value) {
154
+ add(at, value, capture = null) {
154
155
  if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 || at <= (this.#last?.at ?? Number.NEGATIVE_INFINITY)) {
155
156
  return false;
156
157
  }
157
158
  if (this.#last) this.#weighted += this.#last.value * (at - this.#last.at);
158
159
  this.#first ??= { at, value };
159
160
  this.#last = { at, value };
161
+ this.#capture = mergeCaptureSummary(this.#capture, capture);
160
162
  this.#count += 1;
161
163
  this.#sum += value;
162
164
  if (value > 0) this.#inverseSum += 1 / value;
@@ -177,6 +179,10 @@ export class SessionRate {
177
179
  spanSeconds: spanMs / 1000
178
180
  };
179
181
  }
182
+
183
+ captureSnapshot() {
184
+ return this.#capture ? { ...this.#capture } : null;
185
+ }
180
186
  }
181
187
 
182
188
  /**
@@ -192,12 +198,12 @@ export class RateTrend {
192
198
  this.#horizonMs = Math.max(0, horizonSeconds) * 1000;
193
199
  }
194
200
 
195
- add(at, value) {
201
+ add(at, value, capture = null) {
196
202
  if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 ||
197
203
  at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
198
204
  return false;
199
205
  }
200
- this.#readings.push({ at, value });
206
+ this.#readings.push({ at, value, capture: capture ? { ...capture } : null });
201
207
  const cutoff = at - this.#horizonMs;
202
208
  while (this.#readings.length > 1 && this.#readings[1].at <= cutoff) {
203
209
  this.#readings.shift();
@@ -211,6 +217,22 @@ export class RateTrend {
211
217
  }
212
218
  return summarizeRate(this.#readings);
213
219
  }
220
+
221
+ captureSnapshot() {
222
+ return this.#readings.map(reading => reading.capture ? { ...reading.capture } : null);
223
+ }
224
+ }
225
+
226
+ function mergeCaptureSummary(previous, capture) {
227
+ if (!capture || capture.validity !== "valid" || !Number.isFinite(capture.clockEpoch) ||
228
+ !Number.isFinite(capture.startedAt) || !Number.isFinite(capture.completedAt) ||
229
+ capture.completedAt < capture.startedAt) return { validity: "unknown", unknownReason: "missing-rate-capture" };
230
+ if (!previous) return { ...capture };
231
+ if (previous.validity !== "valid" || previous.clockEpoch !== capture.clockEpoch) {
232
+ return { validity: "invalid", unknownReason: "rate-capture-epoch-mismatch" };
233
+ }
234
+ return { ...previous, startedAt: Math.min(previous.startedAt, capture.startedAt),
235
+ completedAt: Math.max(previous.completedAt, capture.completedAt), revision: capture.revision };
214
236
  }
215
237
 
216
238
  /**