@torrent-tv/proxy 2.107.46 → 2.107.48

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.46",
3
+ "version": "2.107.48",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -77,17 +77,15 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
77
77
  const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
78
78
  const linkMbps = Number(req.query?.linkMbps);
79
79
  const linkSampleAgeMs = Number(req.query?.linkSampleAgeMs);
80
- const waitElapsedMs = Number(req.query?.waitElapsedMs);
81
80
  const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
82
81
  ? { linkMbps, linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null }
83
82
  : {};
84
- const earlyWaitAge = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? { waitElapsedMs } : {};
85
83
  const files = contents.files().map(file => {
86
84
  if (!consumerId || !Number.isSafeInteger(generation) || file.kind !== "video" || file.excludedReason) return file;
87
- const waitEstimate = viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey,
85
+ viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey,
88
86
  fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length,
89
- ...earlyLink, ...earlyWaitAge }) ?? null;
90
- return waitEstimate ? { ...file, waitEstimate } : file;
87
+ ...earlyLink });
88
+ return file;
91
89
  });
92
90
  return reply.send({
93
91
  name: torrent.name ?? "",
@@ -20,6 +20,7 @@ import { performance } from "node:perf_hooks";
20
20
  * @returns {Promise<void>}
21
21
  */
22
22
  export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests }) {
23
+ const requestReceivedAtProxyMs = performance.now();
23
24
  const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
24
25
  if (!sourceKey) {
25
26
  return reply.code(400).send({ error: "sourceKey is required." });
@@ -40,15 +41,15 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
40
41
  ? Number(req.query.fileLength) : null;
41
42
  const linkMbps = Number(req.query.linkMbps);
42
43
  const linkSampleAgeMs = Number(req.query.linkSampleAgeMs);
43
- const waitElapsedMs = Number(req.query.waitElapsedMs);
44
44
  const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
45
45
  ? { linkMbps, linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null }
46
46
  : {};
47
- const earlyWaitAge = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? { waitElapsedMs } : {};
48
- const initialEstimate = () => consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)
49
- ? viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey, fileIndex,
50
- fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink, ...earlyWaitAge }) ?? null
51
- : null;
47
+ const noteInitialSnapshot = () => {
48
+ if (consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)) {
49
+ viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey, fileIndex,
50
+ fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink });
51
+ }
52
+ };
52
53
 
53
54
  let torrent;
54
55
  try {
@@ -59,23 +60,25 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
59
60
  return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
60
61
  }
61
62
  if (!torrent) {
62
- const waitEstimate = initialEstimate();
63
- return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
63
+ noteInitialSnapshot();
64
+ return reply.code(202).send({ pending: true });
64
65
  }
65
66
 
66
67
  // Awaited: with the torrent on its own thread this is a round trip, not a
67
68
  // local lookup. Without the await the reply was the pending promise itself,
68
69
  // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
69
70
  const statsStartedAt = performance.now();
71
+ let statsCompletedAt;
70
72
  let stats;
71
73
  try {
72
74
  stats = await torrentPool.getFileStats(torrent, fileIndex);
73
75
  } finally {
76
+ statsCompletedAt = performance.now();
74
77
  logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
75
78
  }
76
79
  if (stats?.pending || !stats) {
77
- const waitEstimate = initialEstimate();
78
- return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
80
+ noteInitialSnapshot();
81
+ return reply.code(202).send({ pending: true });
79
82
  }
80
83
 
81
84
  // Diagnostic: surface the real swarm state per poll so a cold-start download
@@ -149,10 +152,16 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
149
152
  `file=${filePct} header=${header}${detail}`
150
153
  );
151
154
 
152
- const waitEstimate = consumerId && Number.isSafeInteger(generation)
153
- ? viewerRequests?.waitEstimateForSource?.({ consumerId, generation, sourceKey, fileIndex,
155
+ if (consumerId && Number.isSafeInteger(generation)) {
156
+ viewerRequests?.noteWaitSourceSnapshot?.({ consumerId, generation, sourceKey, fileIndex,
154
157
  fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
155
- stats, ...earlyLink, ...earlyWaitAge }) ?? null
156
- : null;
157
- return reply.send(waitEstimate ? { ...stats, waitEstimate } : stats);
158
+ stats, ...earlyLink });
159
+ }
160
+ const responsePreparedAtProxyMs = performance.now();
161
+ return reply.send({ ...stats, sourceSnapshotTransport: {
162
+ requestReceivedAtProxyMs,
163
+ snapshotStartedAtProxyMs: statsStartedAt,
164
+ snapshotCompletedAtProxyMs: statsCompletedAt,
165
+ responsePreparedAtProxyMs
166
+ } });
158
167
  }
@@ -8,7 +8,9 @@ import { recordViewerReport } from "../../../../services/viewer/report-intake.js
8
8
  *
9
9
  * POST /api/transcode-sessions/:sessionId/net-report
10
10
  * Body: { consumerId: string, bufferedAheadSec: number, linkMbps?: number,
11
- * positionSeconds?: number, playingHeight?: number, … }
11
+ * positionSeconds?: number, playingHeight?: number, appendEpoch?: string,
12
+ * appendEpochStartedAt?: number, appendSnapshotAt?: number,
13
+ * appendService?: object, fragmentService?: object, … }
12
14
  *
13
15
  * `consumerId` and `positionSeconds` say WHO is reporting and WHERE they are.
14
16
  * A copied picture is one session shared by every viewer of it, so without them
@@ -115,6 +117,14 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
115
117
  linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
116
118
  linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
117
119
  linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
120
+ appendSessionId: sessionId,
121
+ appendEpoch: typeof body.appendEpoch === "string" ? body.appendEpoch : undefined,
122
+ appendEpochStartedAt: Number.isFinite(body.appendEpochStartedAt) && body.appendEpochStartedAt > 0
123
+ ? body.appendEpochStartedAt : undefined,
124
+ appendSnapshotAt: Number.isFinite(body.appendSnapshotAt) && body.appendSnapshotAt > 0
125
+ ? body.appendSnapshotAt : undefined,
126
+ appendService: body.appendService && typeof body.appendService === "object" ? body.appendService : undefined,
127
+ fragmentService: body.fragmentService && typeof body.fragmentService === "object" ? body.fragmentService : undefined,
118
128
  bufferedAheadSec,
119
129
  bufferedRanges: body.bufferedRanges,
120
130
  timestampOffsets: body.bufferedRanges?.timestampOffsets,
@@ -9,6 +9,7 @@
9
9
  * @returns {Promise<void>}
10
10
  */
11
11
  export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests }) {
12
+ const requestReceivedAtProxyMs = performance.now();
12
13
  const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
13
14
  if (!sessionId) {
14
15
  return reply.code(400).send({ error: "sessionId is required." });
@@ -23,5 +24,11 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewer
23
24
  return reply.code(404).send({ error: "Transcode session was not found." });
24
25
  }
25
26
 
27
+ const responsePreparedAtProxyMs = performance.now();
28
+ progress.snapshotTransport = {
29
+ requestReceivedAtProxyMs,
30
+ snapshotAtProxyMs: progress.playbackReadiness?.snapshotAtProxyMs ?? null,
31
+ responsePreparedAtProxyMs
32
+ };
26
33
  return reply.send(progress);
27
34
  }
@@ -41,6 +41,36 @@ function sameInputs(left, right) {
41
41
  return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
42
42
  }
43
43
 
44
+ function browserServiceSummary(value) {
45
+ if (!value || typeof value !== "object") return null;
46
+ return Object.fromEntries(["init", "media"].filter(kind => value[kind]).map(kind => [kind, value[kind]]).map(([kind, service]) => [kind, {
47
+ bytes: service.bytes,
48
+ elapsedMs: service.elapsedMs,
49
+ operations: service.operations,
50
+ sampleCount: service.samples.length,
51
+ newestSampleAgeMs: service.samples.reduce((age, sample) => age === null ? sample.ageMs : Math.min(age, sample.ageMs), null),
52
+ samples: service.samples.map(({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence, partIndex, fragmentStartSeconds,
53
+ parseElapsedMs, bufferingElapsedMs, bufferingAgeMs, eventAgesMs }) => ({ bytes, elapsedMs, ageMs, fragmentId,
54
+ fragmentSequence, partIndex, fragmentStartSeconds,
55
+ ...(parseElapsedMs === null || parseElapsedMs === undefined ? {} : { parseElapsedMs }),
56
+ ...(bufferingElapsedMs === null || bufferingElapsedMs === undefined ? {} : { bufferingElapsedMs }),
57
+ ...(bufferingAgeMs === null || bufferingAgeMs === undefined ? {} : { bufferingAgeMs }),
58
+ ...(eventAgesMs ? { eventAgesMs } : {}) }))
59
+ }]));
60
+ }
61
+
62
+ function browserServiceFingerprint(tracks) {
63
+ const serviceFingerprint = service => service ? ["init", "media"].map(kind => {
64
+ const value = service[kind];
65
+ return value ? [kind, value.bytes, value.elapsedMs, value.operations, value.samples.map(sample => [
66
+ sample.bytes, sample.elapsedMs, sample.fragmentId, sample.fragmentSequence, sample.partIndex,
67
+ sample.fragmentStartSeconds, sample.parseElapsedMs, sample.bufferingElapsedMs
68
+ ])] : [kind, null];
69
+ }) : null;
70
+ return JSON.stringify(tracks.map(track => [track.id,
71
+ serviceFingerprint(track.appendService), serviceFingerprint(track.fragmentService)]));
72
+ }
73
+
44
74
  export function forecastInputState(tracks, identity) {
45
75
  if (!identity) return null;
46
76
  const track = tracks.find(candidate => candidate.id === identity.trackId);
@@ -91,8 +121,8 @@ export class ViewerRequests {
91
121
  #host;
92
122
  /** @type {WeakMap<object, Map<string, object>>} */
93
123
  #rateStateBySession = new WeakMap();
94
- /** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, revision: number, observedWaits: Set<number> }>} */
95
- #earlyWaits = new Map();
124
+ /** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, observedWaits: Set<number> }>} */
125
+ #waitObservations = new Map();
96
126
  /**
97
127
  * What the start forecasts cost, said once a minute: how many there were,
98
128
  * their time on the main thread, and for each track whether the times of its
@@ -130,17 +160,10 @@ export class ViewerRequests {
130
160
  this.#host = host;
131
161
  }
132
162
 
133
- /**
134
- * Attach a host-matched estimate to the file list before the viewer chooses
135
- * a file, so the original action need not wait for a torrent-worker round trip.
136
- *
137
- * @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number, fileName: string, fileLength: number }} params
138
- * @returns {object | null}
139
- */
140
- waitEstimateForFile({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
141
- waitElapsedMs }) {
163
+ /** Record the facts known while listing a source, without producing an ETA. */
164
+ noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs }) {
142
165
  if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
143
- !Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
166
+ !Number.isSafeInteger(fileIndex) || fileIndex < 0) return;
144
167
  const waitId = `${sourceKey}:${fileIndex}:${generation}`;
145
168
  const key = `${consumerId}:${waitId}`;
146
169
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
@@ -156,48 +179,23 @@ export class ViewerRequests {
156
179
  headerRemaining: null,
157
180
  peers: null
158
181
  };
159
- const current = this.#earlyWaits.get(key);
160
- if (current?.waitId === waitId && current.hasSourceObservation) {
161
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features,
162
- revision: current.revision, current, waitElapsedMs });
163
- const changed = this.#recordEarlyWaitSnapshot(context, context.features);
164
- if (changed) context.revision += 1;
165
- this.#pruneEarlyWaits();
166
- const elapsedMs = waitElapsedSince(context, waitElapsedMs);
167
- const totalSeconds = context.activeTotalSeconds;
168
- const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
169
- if (!Number.isFinite(seconds) || seconds < 0) return null;
170
- this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds,
171
- firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds });
172
- return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
173
- }
174
- const sameWait = current?.waitId === waitId;
175
- const revision = sameWait ? current.revision : 0;
176
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
177
- waitElapsedMs });
178
- const changed = this.#recordEarlyWaitSnapshot(context, context.features);
179
- if (sameWait && changed) context.revision += 1;
180
- this.#pruneEarlyWaits();
181
- const elapsedMs = waitElapsedSince(context, waitElapsedMs);
182
- const totalSeconds = context.activeTotalSeconds;
183
- const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
184
- if (!Number.isFinite(seconds) || seconds < 0) return null;
185
- this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds,
186
- firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds,
187
- firstRequiredStages: context.activeFirstRequiredStages });
188
- return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
182
+ const current = this.#waitObservations.get(key);
183
+ const context = this.#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current });
184
+ this.#recordWaitSnapshot(context);
185
+ this.#pruneWaitObservations();
189
186
  }
190
187
 
191
188
  /**
192
- * Keep the measurements available before the container is inspected and
193
- * predict the complete first-frame wait from this host's earlier outcomes.
189
+ * Record current source and link facts for later forecast evaluation. These
190
+ * partial inputs do not determine the first presented frame, so this method
191
+ * does not produce a wait estimate.
194
192
  *
195
193
  * @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number | null, fileName?: string, fileLength?: number | null, stats?: object }} params
196
194
  * @returns {object | null}
197
195
  */
198
- waitEstimateForSource({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
199
- stats = {}, linkMbps, linkSampleAgeMs, waitElapsedMs, processing = {} }) {
200
- if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return null;
196
+ noteWaitSourceSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
197
+ stats = {}, linkMbps, linkSampleAgeMs, processing = {} }) {
198
+ if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return;
201
199
  const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
202
200
  const key = `${consumerId}:${waitId}`;
203
201
  const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
@@ -220,68 +218,10 @@ export class ViewerRequests {
220
218
  videoSpeedX: processing.videoSpeedX ?? null,
221
219
  audioSpeedX: processing.audioSpeedX ?? null
222
220
  };
223
- const current = this.#earlyWaits.get(key);
224
- const sameWait = current?.waitId === waitId;
225
- const revision = sameWait ? current.revision : 0;
226
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
227
- sourceObserved: true, waitElapsedMs });
228
- const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
229
- const elapsedMs = waitElapsedSince(context, waitElapsedMs);
230
- // The wait ends when the start forecast lets the picture start, and the
231
- // first required pieces being there is only part of that: a source
232
- // supplied slower than the film plays is held until the cushion carries
233
- // it to the end. Counted to the first pieces alone, the estimate reached
234
- // zero with seven minutes still to wait, and the page had no time to show
235
- // (Home Assistant 2026-10-26, Despicable Me 3 BDRemux at 0.53x,
236
- // torrent-tv/meta#239).
237
- const firstRequiredSeconds = processing.firstRequiredSegmentsSeconds;
238
- const startGateSeconds = processing.startGateSeconds;
239
- const measuredWorkSeconds = Number.isFinite(startGateSeconds) && startGateSeconds >= 0
240
- ? Math.max(startGateSeconds, Number.isFinite(firstRequiredSeconds) ? firstRequiredSeconds : 0)
241
- : firstRequiredSeconds;
242
- const hasActiveEstimate = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0;
243
- const stagesChanged = Array.isArray(processing.firstRequiredStages) &&
244
- JSON.stringify(processing.firstRequiredStages) !== JSON.stringify(context.activeFirstRequiredStages);
245
- const activeTotalSeconds = hasActiveEstimate
246
- ? elapsedMs / 1000 + measuredWorkSeconds
247
- : context.activeTotalSeconds;
248
- const activeEstimateChanged = hasActiveEstimate &&
249
- (activeTotalSeconds !== context.activeTotalSeconds || stagesChanged);
250
- if (hasActiveEstimate) {
251
- context.activeTotalSeconds = activeTotalSeconds;
252
- context.activeFirstRequiredSegmentsSeconds = measuredWorkSeconds;
253
- if (Array.isArray(processing.firstRequiredStages)) {
254
- context.activeFirstRequiredStages = processing.firstRequiredStages;
255
- }
256
- }
257
- const changed = featuresChanged || activeEstimateChanged;
258
- if (sameWait && changed) context.revision += 1;
259
- this.#pruneEarlyWaits();
260
- const totalSeconds = activeTotalSeconds;
261
- const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
262
- if (!Number.isFinite(seconds) || seconds < 0) return null;
263
- this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds,
264
- firstRequiredSegmentsSeconds: hasActiveEstimate ? firstRequiredSeconds ?? null : context.activeFirstRequiredSegmentsSeconds,
265
- startGateSeconds: hasActiveEstimate && Number.isFinite(startGateSeconds) ? startGateSeconds : null,
266
- firstRequiredStages: context.activeFirstRequiredStages });
267
- return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
268
- }
269
-
270
- #logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, firstRequiredSegmentsSeconds,
271
- startGateSeconds = null, firstRequiredStages }) {
272
- if (context.loggedForecastRevision === context.revision) return;
273
- const gated = Number.isFinite(startGateSeconds) &&
274
- !(Number.isFinite(firstRequiredSegmentsSeconds) && firstRequiredSegmentsSeconds >= startGateSeconds);
275
- const basis = gated ? "start-gate" : "first-required";
276
- const estimate = { firstRequiredSegmentsSeconds, ...(Number.isFinite(startGateSeconds) ? { startGateSeconds } : {}),
277
- ...(Array.isArray(firstRequiredStages) ? { firstRequiredStages } : {}) };
278
- logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
279
- generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
280
- predicted: { remainingSeconds: seconds, totalSeconds, basis, ...estimate } })}`);
281
- this.#host.observeWait?.(context.sourceKey, "process", { process: "P15", phase: "event",
282
- waitId: context.waitId, consumerId: context.consumerId, generation: context.generation,
283
- elapsedMs, revision: context.revision, remainingSeconds: seconds, totalSeconds, basis, ...estimate });
284
- context.loggedForecastRevision = context.revision;
221
+ const current = this.#waitObservations.get(key);
222
+ const context = this.#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current });
223
+ this.#recordWaitSnapshot(context);
224
+ this.#pruneWaitObservations();
285
225
  }
286
226
 
287
227
  /**
@@ -296,20 +236,19 @@ export class ViewerRequests {
296
236
  */
297
237
  noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs, stages = null }) {
298
238
  const key = `${consumerId}:${waitId}`;
299
- const early = this.#earlyWaits.get(key);
300
- if (!early || early.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || early.observedWaits.has(wait) ||
239
+ const observation = this.#waitObservations.get(key);
240
+ if (!observation || observation.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || observation.observedWaits.has(wait) ||
301
241
  !Number.isFinite(elapsedMs) || elapsedMs < 0) return;
302
- const initialFeatures = early.snapshots[0];
242
+ const initialFeatures = observation.snapshots[0];
303
243
  if (initialFeatures) {
304
244
  this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
305
- featureSnapshots: early.snapshots.slice(1), stages });
245
+ featureSnapshots: observation.snapshots.slice(1), stages });
306
246
  }
307
- early.observedWaits.add(wait);
308
- early.updatedAt = Date.now();
247
+ observation.observedWaits.add(wait);
248
+ observation.updatedAt = Date.now();
309
249
  }
310
250
 
311
- #setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
312
- sourceObserved = false, waitElapsedMs }) {
251
+ #setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current }) {
313
252
  const sameWait = current?.waitId === waitId;
314
253
  const context = sameWait ? current : {
315
254
  consumerId,
@@ -318,26 +257,21 @@ export class ViewerRequests {
318
257
  fileIndex,
319
258
  waitId,
320
259
  snapshots: [],
321
- hasSourceObservation: false,
322
260
  observedWaits: new Set()
323
261
  };
324
262
  const measuredAt = performance.now();
325
- const actionElapsedMs = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? waitElapsedMs : null;
326
- const startedAt = actionElapsedMs === null
327
- ? Number.isFinite(context.startedAt) ? context.startedAt : measuredAt
328
- : measuredAt - actionElapsedMs;
329
263
  const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
330
264
  ? Math.max(0, measuredAt - context.featuresMeasuredAt)
331
265
  : 0;
332
266
  const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
333
267
  Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
334
- featuresMeasuredAt: measuredAt, startedAt, updatedAt: Date.now(), revision,
335
- hasSourceObservation: context.hasSourceObservation || sourceObserved });
336
- this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
268
+ featuresMeasuredAt: measuredAt, updatedAt: Date.now() });
269
+ this.#waitObservations.set(`${consumerId}:${waitId}`, context);
337
270
  return context;
338
271
  }
339
272
 
340
- #recordEarlyWaitSnapshot(context, features) {
273
+ #recordWaitSnapshot(context) {
274
+ const features = context.features;
341
275
  const previous = context.snapshots.at(-1);
342
276
  if (previous && JSON.stringify(previous) === JSON.stringify(features)) return false;
343
277
  const snapshot = { ...features };
@@ -373,12 +307,12 @@ export class ViewerRequests {
373
307
  return bytes;
374
308
  }
375
309
 
376
- #pruneEarlyWaits() {
310
+ #pruneWaitObservations() {
377
311
  const cutoff = Date.now() - 30 * 60_000;
378
- for (const [key, value] of this.#earlyWaits) {
379
- if (value.updatedAt < cutoff) this.#earlyWaits.delete(key);
312
+ for (const [key, value] of this.#waitObservations) {
313
+ if (value.updatedAt < cutoff) this.#waitObservations.delete(key);
380
314
  }
381
- while (this.#earlyWaits.size > 1000) this.#earlyWaits.delete(this.#earlyWaits.keys().next().value);
315
+ while (this.#waitObservations.size > 1000) this.#waitObservations.delete(this.#waitObservations.keys().next().value);
382
316
  }
383
317
 
384
318
  /**
@@ -674,13 +608,12 @@ export class ViewerRequests {
674
608
  videoSpeedX: null,
675
609
  audioSpeedX: null
676
610
  };
677
- // A seek is a new waiting action even when a caller repeats the same
678
- // generation. Start its clock at the explicit seek request, before the
679
- // next readiness poll can return the first current-work estimate.
680
- this.#earlyWaits.delete(key);
681
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey: file.sourceKey,
682
- fileIndex: file.fileIndex, waitId, features, revision: 0, current: null, waitElapsedMs: 0 });
683
- this.#recordEarlyWaitSnapshot(context, features);
611
+ // A seek is a new observation group even when a caller repeats the same
612
+ // generation; its source facts must not join observations from the prior wait.
613
+ this.#waitObservations.delete(key);
614
+ const context = this.#setWaitObservation({ consumerId, generation, sourceKey: file.sourceKey,
615
+ fileIndex: file.fileIndex, waitId, features, current: null });
616
+ this.#recordWaitSnapshot(context);
684
617
  }
685
618
 
686
619
  /**
@@ -1009,6 +942,19 @@ export class ViewerRequests {
1009
942
  clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
1010
943
  clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
1011
944
  viewerReading?.bufferedRanges?.media,
945
+ // Browser-observed append and fragment-parse samples are retained for
946
+ // the full model's service schedule; they are not yet used as numeric
947
+ // predecessors in the partial continuity calculation.
948
+ appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
949
+ ? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
950
+ epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
951
+ receivedAt: viewerReading.appendServiceReceivedAt }
952
+ : null,
953
+ fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
954
+ ? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
955
+ epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
956
+ receivedAt: viewerReading.appendServiceReceivedAt }
957
+ : null,
1012
958
  sourceIds,
1013
959
  processedSeconds: trackProgress?.processedSeconds,
1014
960
  startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
@@ -1089,6 +1035,7 @@ export class ViewerRequests {
1089
1035
  `start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
1090
1036
  const forecast = withSubtitleReadiness(predicted, subtitle,
1091
1037
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
1038
+ forecast.snapshotAtProxyMs = startedAt;
1092
1039
  forecast.operations = outputs.flatMap((output, index) => {
1093
1040
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
1094
1041
  processedSeconds: tracks[index].processedSeconds,
@@ -1109,11 +1056,11 @@ export class ViewerRequests {
1109
1056
  ? this.#host.generationOfRequest(consumerId) : null;
1110
1057
  const earlyWaitId = Number.isSafeInteger(generation)
1111
1058
  ? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
1112
- if (earlyWaitId && this.#earlyWaits.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
1059
+ if (earlyWaitId && this.#waitObservations.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
1113
1060
  const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
1114
1061
  const videoOperation = forecast.operations.find(operation => operation.track === "video");
1115
1062
  const audioOperation = forecast.operations.find(operation => operation.track === "audio");
1116
- const waitEstimate = this.waitEstimateForSource({ consumerId, generation,
1063
+ this.noteWaitSourceSnapshot({ consumerId, generation,
1117
1064
  sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
1118
1065
  fileName: session.file.relativePath ?? session.file.name ?? "",
1119
1066
  fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
@@ -1125,14 +1072,12 @@ export class ViewerRequests {
1125
1072
  videoSpeedX: videoOperation?.speed,
1126
1073
  audioSpeedX: audioOperation?.speed,
1127
1074
  firstRequiredSegmentsSeconds: forecast.firstRequiredSegmentsSeconds,
1128
- // When the start forecast lets the picture start: its delay while
1129
- // it holds the start, and nothing to add once it does not.
1130
1075
  ...(forecast.ready !== true && Number.isFinite(forecast.delaySeconds)
1131
1076
  ? { startGateSeconds: forecast.delaySeconds } : {}),
1132
1077
  firstRequiredStages: forecast.firstRequiredStages
1133
1078
  } });
1134
- if (waitEstimate) forecast.waitEstimate = waitEstimate;
1135
1079
  }
1080
+ forecast.waitEstimate = null;
1136
1081
  const unavailableInput = forecast.unavailableSource;
1137
1082
  const previousInputState = measurement.forecastInputState ?? null;
1138
1083
  const currentInputState = forecastInputState(tracks, unavailableInput);
@@ -1148,9 +1093,13 @@ export class ViewerRequests {
1148
1093
  const forecastInputKey = unavailableInput ? JSON.stringify([
1149
1094
  unavailableInput.sourceId, unavailableInput.trackId, unavailableInput.segmentIndex, unavailableInput.phase
1150
1095
  ]) : null;
1151
- if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey) {
1096
+ const browserEvidenceKey = browserServiceFingerprint(tracks);
1097
+ const browserEvidenceChanged = measurement.browserEvidenceKey !== browserEvidenceKey;
1098
+ if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey ||
1099
+ browserEvidenceChanged) {
1152
1100
  measurement.forecastReason = forecast.reason;
1153
1101
  measurement.forecastInputKey = forecastInputKey;
1102
+ measurement.browserEvidenceKey = browserEvidenceKey;
1154
1103
  if (forecast.subtitles) this.#host.observeWait?.(session.file.sourceKey, "process", {
1155
1104
  process: "C3", phase: "event", fileIndex: session.file.fileIndex,
1156
1105
  trackIndex: forecast.subtitles.trackIndex, ready: forecast.subtitles.ready,
@@ -1172,8 +1121,10 @@ export class ViewerRequests {
1172
1121
  downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
1173
1122
  downloadRateSummaries
1174
1123
  })),
1175
- tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
1176
- id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
1124
+ tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices, appendService, fragmentService }) => ({
1125
+ id, processedSeconds, readings, appendService: browserServiceSummary(appendService),
1126
+ fragmentService: browserServiceSummary(fragmentService), segments: segments.length,
1127
+ prepared: readySegmentIndices.length
1177
1128
  }))
1178
1129
  })}`);
1179
1130
  }
@@ -1466,31 +1417,6 @@ function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
1466
1417
  return { mbps, ageMs };
1467
1418
  }
1468
1419
 
1469
- /**
1470
- * Convert a learned full wait into the time still expected after this action.
1471
- *
1472
- * @param {number | null} totalSeconds
1473
- * @param {number} elapsedMs
1474
- * @returns {number | null}
1475
- */
1476
- function earlyWaitRemainingSeconds(totalSeconds, elapsedMs) {
1477
- if (!Number.isFinite(totalSeconds) || totalSeconds < 0 || !Number.isFinite(elapsedMs) || elapsedMs < 0) return null;
1478
- return Math.max(0, totalSeconds - elapsedMs / 1000);
1479
- }
1480
-
1481
- /**
1482
- * Prefer the browser's monotonic age of the original action; retain a proxy
1483
- * monotonic clock fallback for older pages that do not send it.
1484
- *
1485
- * @param {object} context
1486
- * @param {number | undefined} reportedElapsedMs
1487
- * @returns {number}
1488
- */
1489
- function waitElapsedSince(context, reportedElapsedMs) {
1490
- if (Number.isFinite(reportedElapsedMs) && reportedElapsedMs >= 0) return reportedElapsedMs;
1491
- return Number.isFinite(context.startedAt) ? Math.max(0, performance.now() - context.startedAt) : 0;
1492
- }
1493
-
1494
1420
  /**
1495
1421
  * The download service a source's forecast runs on: what the swarm carried
1496
1422
  * while our selection held none of its connections back, but never less than
@@ -43,6 +43,104 @@
43
43
  import { Assignments } from "./Assignments.js";
44
44
  import { bufferTrend, trendRunStart } from "./buffer-trend.js";
45
45
 
46
+ const APPEND_TRACKS = ["video", "audio"];
47
+ const APPEND_KINDS = ["init", "media"];
48
+ const FRAGMENT_EVENT_AGES = ["loadStart", "loadFirstByte", "loadEnd", "parseStart", "parseEnd",
49
+ "bufferingStart", "bufferingEnd"];
50
+
51
+ function fragmentEventAges(value) {
52
+ return Object.fromEntries(FRAGMENT_EVENT_AGES.map(name => [name,
53
+ Number.isFinite(value?.[name]) && value[name] >= 0 ? value[name] : null]));
54
+ }
55
+
56
+ function appendServiceSnapshot(value) {
57
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
58
+ const snapshot = {};
59
+ for (const track of APPEND_TRACKS) {
60
+ if (!value[track] || typeof value[track] !== "object" || Array.isArray(value[track])) return null;
61
+ snapshot[track] = {};
62
+ for (const kind of APPEND_KINDS) {
63
+ const item = value[track][kind];
64
+ if (!item || !Number.isSafeInteger(item.bytes) || item.bytes < 0 || !Number.isSafeInteger(item.operations) ||
65
+ item.operations < 0 || !Number.isFinite(item.elapsedMs) || item.elapsedMs < 0 ||
66
+ (item.operations === 0 && (item.bytes > 0 || item.elapsedMs > 0)) ||
67
+ (item.operations > 0 && (item.bytes === 0 || item.elapsedMs === 0)) || !Array.isArray(item.samples) ||
68
+ !item.samples.every(sample => Number.isSafeInteger(sample?.bytes) && sample.bytes > 0 &&
69
+ Number.isFinite(sample.elapsedMs) && sample.elapsedMs > 0 &&
70
+ Number.isFinite(sample.ageMs) && sample.ageMs >= 0 &&
71
+ (sample.fragmentId === null || typeof sample.fragmentId === "string" && sample.fragmentId.length <= 128) &&
72
+ (sample.fragmentSequence === undefined || sample.fragmentSequence === null ||
73
+ Number.isSafeInteger(sample.fragmentSequence) && sample.fragmentSequence >= 0) &&
74
+ (sample.partIndex === undefined || sample.partIndex === null ||
75
+ Number.isSafeInteger(sample.partIndex) && sample.partIndex >= 0) &&
76
+ (sample.fragmentStartSeconds === null || Number.isFinite(sample.fragmentStartSeconds)))) return null;
77
+ snapshot[track][kind] = { bytes: item.bytes, elapsedMs: item.elapsedMs, operations: item.operations,
78
+ samples: item.samples.map(({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence, partIndex,
79
+ fragmentStartSeconds }) => ({ bytes, elapsedMs, ageMs, fragmentId,
80
+ fragmentSequence: fragmentSequence ?? null, partIndex: partIndex ?? null, fragmentStartSeconds })) };
81
+ }
82
+ }
83
+ return snapshot;
84
+ }
85
+
86
+ function fragmentServiceSnapshot(value) {
87
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
88
+ const snapshot = {};
89
+ for (const track of APPEND_TRACKS) {
90
+ if (!value[track] || typeof value[track] !== "object" || Array.isArray(value[track])) return null;
91
+ snapshot[track] = {};
92
+ for (const kind of APPEND_KINDS) {
93
+ const item = value[track][kind];
94
+ if (!item || typeof item !== "object" || Array.isArray(item) || !Number.isSafeInteger(item.bytes) || item.bytes < 0 ||
95
+ !Number.isFinite(item.elapsedMs) || item.elapsedMs < 0 || !Number.isSafeInteger(item.operations) ||
96
+ item.operations < 0 || (item.operations === 0 && (item.bytes > 0 || item.elapsedMs > 0)) ||
97
+ (item.operations > 0 && (item.bytes === 0 || item.elapsedMs === 0)) || !Array.isArray(item.samples) ||
98
+ !item.samples.every(sample => Number.isSafeInteger(sample?.bytes) && sample.bytes > 0 &&
99
+ Number.isFinite(sample.elapsedMs) && sample.elapsedMs > 0 &&
100
+ Number.isFinite(sample.ageMs) && sample.ageMs >= 0 &&
101
+ (sample.parseElapsedMs === undefined || sample.parseElapsedMs === null ||
102
+ Number.isFinite(sample.parseElapsedMs) && sample.parseElapsedMs >= 0) &&
103
+ (sample.bufferingElapsedMs === undefined || sample.bufferingElapsedMs === null ||
104
+ Number.isFinite(sample.bufferingElapsedMs) && sample.bufferingElapsedMs >= 0) &&
105
+ (sample.bufferingAgeMs === undefined || sample.bufferingAgeMs === null ||
106
+ Number.isFinite(sample.bufferingAgeMs) && sample.bufferingAgeMs >= 0) &&
107
+ (sample.eventAgesMs === undefined || sample.eventAgesMs && typeof sample.eventAgesMs === "object" &&
108
+ !Array.isArray(sample.eventAgesMs) && Object.values(sample.eventAgesMs).every(age =>
109
+ age === null || Number.isFinite(age) && age >= 0)) &&
110
+ (sample.fragmentId === null || typeof sample.fragmentId === "string" && sample.fragmentId.length <= 128) &&
111
+ (sample.fragmentSequence === undefined || sample.fragmentSequence === null ||
112
+ Number.isSafeInteger(sample.fragmentSequence) && sample.fragmentSequence >= 0) &&
113
+ (sample.partIndex === undefined || sample.partIndex === null ||
114
+ Number.isSafeInteger(sample.partIndex) && sample.partIndex >= 0) &&
115
+ (sample.fragmentStartSeconds === null || Number.isFinite(sample.fragmentStartSeconds)))) {
116
+ return null;
117
+ }
118
+ snapshot[track][kind] = { bytes: item.bytes, elapsedMs: item.elapsedMs, operations: item.operations,
119
+ samples: item.samples.map(({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence, partIndex, fragmentStartSeconds,
120
+ parseElapsedMs, bufferingElapsedMs, bufferingAgeMs, eventAgesMs }) =>
121
+ ({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence: fragmentSequence ?? null,
122
+ partIndex: partIndex ?? null, fragmentStartSeconds,
123
+ parseElapsedMs: parseElapsedMs ?? null, bufferingElapsedMs: bufferingElapsedMs ?? null,
124
+ bufferingAgeMs: bufferingAgeMs ?? null, eventAgesMs: fragmentEventAges(eventAgesMs) })) };
125
+ }
126
+ }
127
+ return snapshot;
128
+ }
129
+
130
+ function appendServiceIsMonotonic(next, previous) {
131
+ return APPEND_TRACKS.every(track => APPEND_KINDS.every(kind =>
132
+ next[track][kind].bytes >= previous[track][kind].bytes &&
133
+ next[track][kind].elapsedMs >= previous[track][kind].elapsedMs &&
134
+ next[track][kind].operations >= previous[track][kind].operations));
135
+ }
136
+
137
+ function fragmentServiceIsMonotonic(next, previous) {
138
+ return APPEND_TRACKS.every(track => APPEND_KINDS.every(kind =>
139
+ next[track][kind].bytes >= previous[track][kind].bytes &&
140
+ next[track][kind].elapsedMs >= previous[track][kind].elapsedMs &&
141
+ next[track][kind].operations >= previous[track][kind].operations));
142
+ }
143
+
46
144
  /**
47
145
  * How long a generation this viewer has left behind goes on accepting NEW
48
146
  * requests.
@@ -373,6 +471,11 @@ export class Viewer {
373
471
  * report still stands: this is a statement about a viewer, not about a link.
374
472
  * @param {number} [report.linkSampleAgeMs] - How old the newest raw link
375
473
  * sample was when this report was made.
474
+ * @param {string} [report.appendSessionId] - Which output the page measured appends for.
475
+ * @param {string} [report.appendEpoch] - Counter identity; a new page reporter starts a new epoch.
476
+ * @param {number} [report.appendEpochStartedAt] - Browser epoch start clock used to reject delayed reports from an older reporter.
477
+ * @param {object} [report.appendService] - Cumulative append totals and recent per-operation byte/time samples.
478
+ * @param {object} [report.fragmentService] - Cumulative fragment parse totals and recent per-fragment samples.
376
479
  * @param {number} report.bufferedAheadSec
377
480
  * @param {number} [report.bufferLimitSeconds] - The largest forward buffer
378
481
  * this browser accepted for the current player.
@@ -399,6 +502,12 @@ export class Viewer {
399
502
  linkSampleMbps = null,
400
503
  linkSampleAt = null,
401
504
  linkSampleAgeMs = null,
505
+ appendSessionId = null,
506
+ appendEpoch = null,
507
+ appendEpochStartedAt = null,
508
+ appendSnapshotAt = null,
509
+ appendService: appendServiceReading = null,
510
+ fragmentService: fragmentServiceReading = null,
402
511
  bufferedAheadSec,
403
512
  bufferedRanges,
404
513
  timestampOffsets,
@@ -439,6 +548,20 @@ export class Viewer {
439
548
  const sampleMbps = Number(linkSampleMbps);
440
549
  const sampleAt = Number(linkSampleAt);
441
550
  const previous = this.netReport;
551
+ const incomingAppendService = typeof appendEpoch === "string" && appendEpoch.length > 0
552
+ ? appendServiceSnapshot(appendServiceReading) : null;
553
+ const incomingFragmentService = typeof appendEpoch === "string" && appendEpoch.length > 0
554
+ ? fragmentServiceSnapshot(fragmentServiceReading) : null;
555
+ const sameAppendEpoch = appendSessionId === previous?.appendSessionId && appendEpoch === previous?.appendEpoch;
556
+ const validAppendEpochTime = Number.isFinite(appendEpochStartedAt) && appendEpochStartedAt > 0;
557
+ const validAppendSnapshotTime = Number.isFinite(appendSnapshotAt) && appendSnapshotAt >= appendEpochStartedAt;
558
+ const useBrowserService = !stale && typeof appendSessionId === "string" && appendSessionId.length > 0 &&
559
+ incomingAppendService && incomingFragmentService &&
560
+ validAppendEpochTime && validAppendSnapshotTime && (sameAppendEpoch
561
+ ? appendEpochStartedAt === previous?.appendEpochStartedAt && appendSnapshotAt > (previous?.appendSnapshotAt ?? 0) &&
562
+ (!previous?.appendService || appendServiceIsMonotonic(incomingAppendService, previous.appendService)) &&
563
+ (!previous?.fragmentService || fragmentServiceIsMonotonic(incomingFragmentService, previous.fragmentService))
564
+ : appendEpochStartedAt > (previous?.appendEpochStartedAt ?? 0));
442
565
  const hasPlaybackSample = Number.isFinite(sampleMbps) && sampleMbps > 0 &&
443
566
  Number.isFinite(sampleAt) && sampleAt > 0;
444
567
  const newerPlaybackSample = hasPlaybackSample && sampleAt > (this.netReport?.linkSampleAt ?? 0);
@@ -452,6 +575,13 @@ export class Viewer {
452
575
  ? now - linkSampleAgeMs
453
576
  : now
454
577
  : this.netReport?.linkSampleMeasuredAt ?? null,
578
+ appendSessionId: useBrowserService ? appendSessionId : previous?.appendSessionId ?? null,
579
+ appendEpoch: useBrowserService ? appendEpoch : previous?.appendEpoch ?? null,
580
+ appendEpochStartedAt: useBrowserService ? appendEpochStartedAt : previous?.appendEpochStartedAt ?? null,
581
+ appendSnapshotAt: useBrowserService ? appendSnapshotAt : previous?.appendSnapshotAt ?? null,
582
+ appendService: useBrowserService ? incomingAppendService : previous?.appendService ?? null,
583
+ fragmentService: useBrowserService ? incomingFragmentService : previous?.fragmentService ?? null,
584
+ appendServiceReceivedAt: useBrowserService ? now : previous?.appendServiceReceivedAt ?? null,
455
585
  bufferedAheadSec: held,
456
586
  timestampOffsets: Object.fromEntries(["video", "audio"].filter((key) =>
457
587
  Number.isFinite(timestampOffsets?.[key])).map((key) => [key, timestampOffsets[key]])),
@@ -42,6 +42,46 @@ import {
42
42
  // timestamps can extend slightly beyond its nominal playlist boundary.
43
43
  export const PLAYER_SEEK_TOLERANCE_SECONDS = 0.25;
44
44
 
45
+ /**
46
+ * Choose a point for a supported completion-time distribution under absolute
47
+ * relative error. For positive completions, minimizing E[|x - T| / T] is a
48
+ * weighted-median problem with mass p/T. A distribution mixing immediate and
49
+ * positive completion has no point under this loss definition.
50
+ *
51
+ * This summarizes an already-supported probability distribution; it does not
52
+ * fit one from completed whole waits or supply missing service observations.
53
+ *
54
+ * @param {Array<{ completionSeconds: number, probability: number }>} support
55
+ * @returns {{ pointSeconds: number | null, immediateProbability: number, supportCount: number } | null}
56
+ */
57
+ export function completionPointUnderRelativeLoss(support) {
58
+ if (!Array.isArray(support) || support.length === 0 || support.some(sample =>
59
+ !Number.isFinite(sample?.completionSeconds) || sample.completionSeconds < 0 ||
60
+ !Number.isFinite(sample?.probability) || sample.probability < 0)) return null;
61
+ const positiveMass = support.filter(sample => sample.probability > 0);
62
+ const totalProbability = positiveMass.reduce((sum, sample) => sum + sample.probability, 0);
63
+ if (!(totalProbability > 0) || !Number.isFinite(totalProbability)) return null;
64
+ const immediateProbability = positiveMass.filter(sample => sample.completionSeconds === 0)
65
+ .reduce((sum, sample) => sum + sample.probability, 0) / totalProbability;
66
+ const positive = positiveMass.filter(sample => sample.completionSeconds > 0);
67
+ if (positive.length === 0) return { pointSeconds: 0, immediateProbability: 1, supportCount: positiveMass.length };
68
+ if (immediateProbability > 0) {
69
+ return { pointSeconds: null, immediateProbability, supportCount: positiveMass.length };
70
+ }
71
+ const weighted = positive.map(sample => ({ seconds: sample.completionSeconds,
72
+ weight: sample.probability / sample.completionSeconds })).sort((left, right) => left.seconds - right.seconds);
73
+ const totalWeight = weighted.reduce((sum, sample) => sum + sample.weight, 0);
74
+ if (!(totalWeight > 0) || !Number.isFinite(totalWeight)) return null;
75
+ let cumulativeWeight = 0;
76
+ for (const sample of weighted) {
77
+ cumulativeWeight += sample.weight;
78
+ if (cumulativeWeight * 2 >= totalWeight) {
79
+ return { pointSeconds: sample.seconds, immediateProbability: 0, supportCount: positiveMass.length };
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
45
85
  /**
46
86
  * @param {number} segmentEndSeconds
47
87
  * @param {number} positionSeconds
@@ -191,6 +231,7 @@ export class RateTrend {
191
231
  * segmentCount?: number,
192
232
  * bitsPerMediaSecond: number, readings: Array<{ at: number, value: number }>,
193
233
  * clockOffsetSeconds?: number, clientRanges?: Array<{ start: number, end: number }>,
234
+ * appendService?: object, fragmentService?: object,
194
235
  * segments: Array<{ index: number, startSeconds: number, endSeconds: number,
195
236
  * mediaRanges?: { timescale: bigint | null, ranges: Array<{ start: bigint, end: bigint, frame: bigint }> } }>,
196
237
  * readySegmentIndices: number[], segmentSizesBytes: Map<number, number> | object }>} input.tracks
@@ -300,6 +341,13 @@ export function predictPlaybackReadiness(input = {}) {
300
341
  : Math.max(0, ...track.segments.map(segment => segment.index + 1)),
301
342
  clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
302
343
  }));
344
+ const missingFirstGeometry = trackState.slice(0, requiredTracks).some(({ segments }) => {
345
+ const first = segments.find(segment => segment.endSeconds > position);
346
+ return first && first.ranges.length === 0;
347
+ });
348
+ if (missingFirstGeometry) {
349
+ return result(false, null, buffered, reserve, null, "first-required-media-ranges-unavailable", 0);
350
+ }
303
351
  for (const state of trackState) {
304
352
  state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
305
353
  ? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
@@ -707,16 +755,68 @@ export function predictPlaybackReadiness(input = {}) {
707
755
  const first = state.segments.find(segment => segment.endSeconds > position);
708
756
  if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
709
757
  sourceArrivalSeconds: 0, runAdmissionSeconds: null, runOpenSeconds: 0, productionStartSeconds: 0,
710
- productionCompleteSeconds: 0, deliveryCompleteSeconds: 0, appendSeconds: 0, completionSeconds: 0 };
758
+ productionCompleteSeconds: 0, deliveryCompleteSeconds: 0, browserLoadToParseSeconds: 0,
759
+ appendRequestToAckSeconds: 0, appendSeconds: 0, completionSeconds: 0 };
760
+ if (!Array.isArray(first.ranges) || first.ranges.length === 0) return {
761
+ track: state.track.id ?? trackIndex,
762
+ segment: first.index,
763
+ alreadyBuffered: null,
764
+ mediaRangesReason: "first-required-media-ranges-unavailable",
765
+ sourceArrivalSeconds: null,
766
+ runAdmissionSeconds: null,
767
+ runOpenSeconds: null,
768
+ productionStartSeconds: null,
769
+ productionCompleteSeconds: null,
770
+ deliveryCompleteSeconds: null,
771
+ browserLoadToParseSeconds: null,
772
+ browserLoadToParseReason: "fragment-load-to-parse-distribution-unavailable",
773
+ appendRequestToAckSeconds: null,
774
+ appendRequestToAckReason: "append-request-to-ack-distribution-unavailable",
775
+ appendSeconds: null,
776
+ appendReason: "append-request-to-ack-distribution-unavailable",
777
+ completionSeconds: null
778
+ };
711
779
  if (first.ranges.every(range => contains(state.clientRanges, range.start, range.end))) {
712
780
  return { track: state.track.id ?? trackIndex, segment: first.index, alreadyBuffered: true,
713
781
  sourceArrivalSeconds: 0, runAdmissionSeconds: null, runOpenSeconds: 0, productionStartSeconds: 0,
714
- productionCompleteSeconds: 0, deliveryCompleteSeconds: 0, appendSeconds: 0, completionSeconds: 0 };
782
+ productionCompleteSeconds: 0, deliveryCompleteSeconds: 0, browserLoadToParseSeconds: 0,
783
+ appendRequestToAckSeconds: 0, appendSeconds: 0, completionSeconds: 0 };
715
784
  }
716
785
  const completion = completions.find(item => item.trackIndex === trackIndex && item.segment.index === first.index);
786
+ // HLS playlists for these generated outputs use MEDIA-SEQUENCE:0, so a
787
+ // complete media fragment's HLS sequence number is the output segment
788
+ // index. Keep matched browser observations as evidence only: their recent
789
+ // completed-work spans are not a future-service distribution.
790
+ const samplesForFirstSegment = (kind) => (service) => {
791
+ const samples = service?.[kind]?.samples;
792
+ return Array.isArray(samples) ? samples.filter(sample => sample.fragmentSequence === first.index &&
793
+ sample.partIndex === null) : [];
794
+ };
795
+ const observedBrowserWork = {
796
+ evidenceOnly: true,
797
+ fragmentLoadToParse: samplesForFirstSegment("media")(state.track.fragmentService).map(sample => ({
798
+ bytes: sample.bytes,
799
+ elapsedMs: sample.elapsedMs,
800
+ parseElapsedMs: sample.parseElapsedMs ?? null,
801
+ bufferingElapsedMs: sample.bufferingElapsedMs ?? null,
802
+ ageMs: sample.ageMs,
803
+ bufferingAgeMs: sample.bufferingAgeMs ?? null,
804
+ eventAgesMs: sample.eventAgesMs ?? null
805
+ })),
806
+ appendRequestToAck: samplesForFirstSegment("media")(state.track.appendService).map(sample => ({
807
+ bytes: sample.bytes,
808
+ elapsedMs: sample.elapsedMs,
809
+ ageMs: sample.ageMs
810
+ }))
811
+ };
812
+ const observedBrowserWorkField = observedBrowserWork.fragmentLoadToParse.length > 0 ||
813
+ observedBrowserWork.appendRequestToAck.length > 0 ? { observedBrowserWork } : {};
717
814
  if (!completion) return { track: state.track.id ?? trackIndex, segment: first.index, alreadyBuffered: false,
718
815
  sourceArrivalSeconds: null, runAdmissionSeconds: null, runOpenSeconds: null, productionStartSeconds: null,
719
- productionCompleteSeconds: null, deliveryCompleteSeconds: null, appendSeconds: null, completionSeconds: null };
816
+ productionCompleteSeconds: null, deliveryCompleteSeconds: null, browserLoadToParseSeconds: null,
817
+ browserLoadToParseReason: "fragment-load-to-parse-distribution-unavailable",
818
+ appendRequestToAckSeconds: null, appendRequestToAckReason: "append-request-to-ack-distribution-unavailable",
819
+ appendSeconds: null, completionSeconds: null, ...observedBrowserWorkField };
720
820
  const production = productions.find(item => item.trackIndex === trackIndex && item.segment.index === first.index);
721
821
  return { track: state.track.id ?? trackIndex, segment: first.index, alreadyBuffered: false,
722
822
  sourceArrivalSeconds: production?.sourceArrivalAt ?? 0,
@@ -725,8 +825,14 @@ export function predictPlaybackReadiness(input = {}) {
725
825
  productionStartSeconds: production?.productionStartAt ?? null,
726
826
  productionCompleteSeconds: production?.at ?? null,
727
827
  deliveryCompleteSeconds: completion.deliveryAt,
728
- appendSeconds: completion.appendSeconds,
729
- completionSeconds: completion.at };
828
+ browserLoadToParseSeconds: null,
829
+ browserLoadToParseReason: "fragment-load-to-parse-distribution-unavailable",
830
+ appendRequestToAckSeconds: null,
831
+ appendRequestToAckReason: "append-request-to-ack-distribution-unavailable",
832
+ appendSeconds: null,
833
+ appendReason: "append-request-to-ack-distribution-unavailable",
834
+ completionSeconds: null,
835
+ ...observedBrowserWorkField };
730
836
  });
731
837
  const firstRequiredTrackSeconds = firstRequiredStages.map(stage => stage.completionSeconds);
732
838
  const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)