@torrent-tv/proxy 2.107.53 → 2.107.55

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.55",
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,25 @@ 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));
870
+ const forecastGeometry = this.#host.outputForecastGeometry.get(output);
798
871
  const sourceIndexes = [...output.spec.sourceFileIndexes];
799
872
  if (sourceIndexes.length === 0) {
800
873
  sourceIndexes.push(output.file.fileIndex);
@@ -814,36 +887,62 @@ export class ViewerRequests {
814
887
  } catch {
815
888
  stats = null;
816
889
  }
817
- if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
890
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
818
891
  if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
819
892
  parts.statsMs += performance.now() - statsAt;
820
893
  parts.workerForecastMs += Number(stats?.forecastMs) || 0;
894
+ const sourceCapture = sourceCaptureOf(stats, actionId, generation);
821
895
  if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
822
896
  const sharedForecast = sourceForecasts.get(output.file.sourceKey);
823
- const downloadForecast = sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
897
+ const capturedForecast = sourceCaptureOf({ sourceCapture: sharedForecast?.capture }, actionId, generation);
898
+ const forecastCapture = sharedForecast?.global === true && !Array.isArray(sharedForecast.ranges)
899
+ ? { ...capturedForecast, validity: "unknown", unknownReason: "source-arrival-ranges-unavailable" }
900
+ : capturedForecast;
901
+ const downloadForecast = sourceCapture.validity !== "valid" || forecastCapture.validity !== "valid"
902
+ ? { ranges: [] } : sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
824
903
  ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
825
904
  start: Math.max(0, range.start - stats.fileOffset),
826
905
  end: Math.min(stats.fileLength, range.end - stats.fileOffset)
827
906
  })).filter(range => range.end > range.start) } : sharedForecast;
907
+ const sourceFactsValid = sourceCapture.validity === "valid";
908
+ const ownsSourceState = viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement);
909
+ const sourceReadingsValid = sourceFactsValid && Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0;
910
+ const existingSourceCaptures = ownsSourceState
911
+ ? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
912
+ const sourceEpochChanged = sourceReadingsValid && existingSourceCaptures &&
913
+ [existingSourceCaptures.recent?.delivered, existingSourceCaptures.recent?.free,
914
+ existingSourceCaptures.tail?.delivered, existingSourceCaptures.tail?.free].some(capture =>
915
+ capture?.validity === "valid" && capture.clockEpoch !== sourceCapture.clockEpoch);
916
+ if (ownsSourceState && (!sourceReadingsValid || sourceEpochChanged)) {
917
+ this.#host.viewerPlaybackState.forgetSourceReadings(measurement, output.file.sourceKey);
918
+ }
919
+ const downloadRateReadings = ownsSourceState && sourceReadingsValid
920
+ ? this.#host.viewerPlaybackState.sourceRateReadings(measurement, output.file.sourceKey, stats, now,
921
+ this.#host.playbackReadiness, this.#host.lookaheadSeconds, sourceCapture) : null;
922
+ const tailRateReadings = ownsSourceState && sourceReadingsValid
923
+ ? this.#host.viewerPlaybackState.sourceTailRateReadings(measurement, output.file.sourceKey, now,
924
+ this.#host.playbackReadiness) : null;
925
+ const downloadRateSummaries = ownsSourceState && sourceReadingsValid
926
+ ? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null;
927
+ const rateCapture = ownsSourceState && sourceReadingsValid
928
+ ? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
828
929
  sourceMeasurements.set(sourceId, {
829
930
  id: sourceId,
830
931
  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 &&
932
+ complete: sourceFactsValid ? stats?.fileAvailable === true : null,
933
+ residence: sourceFactsValid ? stats?.residence : null,
934
+ fileOffset: sourceFactsValid ? stats?.fileOffset : null,
935
+ fileLength: sourceFactsValid ? stats?.fileLength : null,
936
+ bytesPerMediaSecond: sourceFactsValid && Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
836
937
  Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
837
938
  ? 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,
939
+ pieceLength: sourceFactsValid ? stats?.pieceLength : null,
940
+ downloadRateReadings,
941
+ tailRateReadings,
942
+ downloadRateSummaries,
943
+ capture: { ...sourceCapture, rateReadings: rateCapture ?? {
944
+ validity: "unknown", unknownReason: "source-rate-capture-unavailable" },
945
+ downloadForecast: forecastCapture },
847
946
  downloadForecast,
848
947
  });
849
948
  }
@@ -852,36 +951,38 @@ export class ViewerRequests {
852
951
  const observedMbps = output === session
853
952
  ? outputMbps
854
953
  : await this.#host.quality.observedStreamMbps(output);
855
- if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
954
+ if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
856
955
  parts.qualityMs += performance.now() - qualityAt;
857
956
  const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
957
+ const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
958
+ const encoderCaptureValid = encoderCapture.validity === "valid";
858
959
  const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
859
960
  const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
860
961
  tracks.push({
861
962
  id: output.outputKey,
862
963
  clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
863
- clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
864
- viewerReading?.bufferedRanges?.media,
964
+ clientRanges: bufferCaptureValid ? viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
965
+ viewerReading?.bufferedRanges?.media : null,
865
966
  // Browser-observed append and fragment-parse samples are retained for
866
967
  // the full model's service schedule; they are not yet used as numeric
867
968
  // predecessors in the partial continuity calculation.
868
- appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
969
+ appendService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
869
970
  ? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
870
971
  epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
871
972
  receivedAt: viewerReading.appendServiceReceivedAt }
872
973
  : null,
873
- fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
974
+ fragmentService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
874
975
  ? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
875
976
  epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
876
977
  receivedAt: viewerReading.appendServiceReceivedAt }
877
978
  : null,
878
979
  sourceIds,
879
- processedSeconds: trackProgress?.processedSeconds,
880
- startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
980
+ processedSeconds: encoderCaptureValid ? trackProgress?.processedSeconds : null,
981
+ startupRemainingSeconds: !encoderCaptureValid ? null : processingStarted ? 0 : Math.max(0,
881
982
  (this.#host.processingStartupSecondsOf?.(output) ?? 0) - Math.max(0, (now - startedAt) / 1000)),
882
- processingRanges: liveRuns.map(({ progress }) => ({
983
+ processingRanges: encoderCaptureValid ? liveRuns.map(({ progress }) => ({
883
984
  start: progress.startPositionSeconds, end: progress.processedSeconds
884
- })),
985
+ })) : [],
885
986
  // What a run's start costs this output, and the stretches its live
886
987
  // runs were given: the forecast starts a run wherever production meets
887
988
  // bytes that were not held when the run before was admitted.
@@ -894,18 +995,20 @@ export class ViewerRequests {
894
995
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
895
996
  durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
896
997
  segmentCount: output.timeline?.segmentCount ?? segments.length,
897
- readings: trackRates,
998
+ readings: encoderCaptureValid ? trackRates : [],
999
+ capture: encoderCapture,
898
1000
  segments,
899
- geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
1001
+ tailSegments: forecastGeometry?.tailSegments ?? [],
1002
+ geometry: forecastGeometry?.geometry,
900
1003
  readySegmentIndices: [...inventory.keys()],
901
1004
  segmentSizesBytes: inventory
902
1005
  });
903
1006
  }
904
1007
 
905
- if (!stillOwnsProgress()) return null;
1008
+ if (!stillOwnsProgress() || !actionIsCurrent()) return null;
906
1009
 
907
- const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
908
- const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
1010
+ const bufferedAheadSeconds = bufferCaptureValid ? Number(viewerReading?.bufferedAheadSec) : null;
1011
+ const positionSeconds = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
909
1012
  ? viewerReading.positionSeconds
910
1013
  : this.#host.viewerSecondsOn(session, consumerId, now);
911
1014
  // Both separately delivered tracks can run short independently. Use the
@@ -932,7 +1035,7 @@ export class ViewerRequests {
932
1035
  positionSeconds,
933
1036
  durationSeconds: session.file.durationSeconds,
934
1037
  bufferedAheadSeconds,
935
- bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
1038
+ bufferLimitSeconds: bufferCaptureValid ? viewerReading?.bufferLimitSeconds : null,
936
1039
  reserveSeconds,
937
1040
  lookaheadSeconds: this.#host.lookaheadSeconds,
938
1041
  // Disk room remaining after stock reserved for other active outputs.
@@ -940,7 +1043,7 @@ export class ViewerRequests {
940
1043
  requiredAudio: requiresSeparateAudio,
941
1044
  sources: [...sourceMeasurements.values()],
942
1045
  tracks,
943
- linkReadings: measurement.link.snapshot()
1046
+ linkReadings: viewerReading?.linkCapture?.validity === "valid" ? measurement.link.snapshot() : null
944
1047
  };
945
1048
  // A subtitle track chosen inside THIS file is the last input the forecast
946
1049
  // waits for. A subtitle FILE beside it is named by its own index and is
@@ -960,6 +1063,18 @@ export class ViewerRequests {
960
1063
  const forecast = withSubtitleReadiness(predicted, subtitle,
961
1064
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
962
1065
  forecast.snapshotAtProxyMs = startedAt;
1066
+ forecast.snapshotClockEpoch = performance.timeOrigin;
1067
+ forecast.inputCaptures = {
1068
+ source: [...sourceMeasurements].map(([id, source]) => ({ id, capture: source.capture })),
1069
+ link: { ...(viewerReading?.linkCapture ?? unknownCapture("browser", actionId, generation,
1070
+ "link-capture-unavailable")), rateReadings: measurement.link.captureSnapshot() },
1071
+ encoder: tracks.map(track => ({ id: track.id, capture: track.capture })),
1072
+ buffer: bufferCaptureValid || rawBufferCapture.validity === "unknown" ? rawBufferCapture : {
1073
+ ...rawBufferCapture, validity: "invalid",
1074
+ unknownReason: rawBufferCapture.generation !== generation ? "seek-generation-replaced" :
1075
+ rawBufferCapture.actionId !== session.id ? "viewer-session-replaced" : "viewer-output-cancelled"
1076
+ }
1077
+ };
963
1078
  forecast.operations = outputs.flatMap((output, index) => {
964
1079
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
965
1080
  processedSeconds: tracks[index].processedSeconds,
@@ -976,8 +1091,6 @@ export class ViewerRequests {
976
1091
  outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
977
1092
  ];
978
1093
  });
979
- const generation = consumerId && viewer?.assignments
980
- ? this.#host.generationOfRequest(consumerId) : null;
981
1094
  const earlyWaitId = Number.isSafeInteger(generation)
982
1095
  ? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
983
1096
  if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
@@ -1136,7 +1249,20 @@ export class ViewerRequests {
1136
1249
  #trackRateReadings(output, now) {
1137
1250
  const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
1138
1251
  const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
1139
- if (summary) return summary;
1252
+ if (summary) return { summary, captures: readings.map(reading => ({ owner: reading.captureOwner,
1253
+ clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
1254
+ startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
1255
+ revision: reading.at,
1256
+ validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
1257
+ Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
1258
+ Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
1259
+ reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
1260
+ ? "valid" : "unknown",
1261
+ unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
1262
+ Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
1263
+ Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
1264
+ reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
1265
+ ? null : "encoder-sample-capture-unavailable" })) };
1140
1266
  // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
1141
1267
  // encoder, and it is not kept among the readings. Added on every poll
1142
1268
  // between runs, it put the machine's share of that moment — 1 % at a
@@ -1146,7 +1272,9 @@ export class ViewerRequests {
1146
1272
  if (Number.isFinite(projected) && projected > 0) {
1147
1273
  const seed = new this.#host.playbackReadiness.RateTrend();
1148
1274
  seed.add(now, projected);
1149
- return seed.snapshot();
1275
+ return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
1276
+ clockEpoch: performance.timeOrigin, startedAt: now, completedAt: now, revision: now,
1277
+ validity: "valid", unknownReason: null }] };
1150
1278
  }
1151
1279
  // Progress over wall time includes source waits, which the forecast prices
1152
1280
  // separately. Keep the rate unknown until a measured or calibrated