@torrent-tv/proxy 2.107.47 → 2.107.49

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.47",
3
+ "version": "2.107.49",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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." });
@@ -67,10 +68,12 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
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) {
@@ -154,5 +157,11 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
154
157
  fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
155
158
  stats, ...earlyLink });
156
159
  }
157
- return reply.send(stats);
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
  }
@@ -427,6 +427,7 @@ export class OutputLifecycle {
427
427
  if (!output) {
428
428
  return false;
429
429
  }
430
+ this.#host.releasePlaybackStorageDemand?.(output.outputKey ?? "", consumerId);
430
431
  // Nothing to release in the encoding: it holds one map per output, built
431
432
  // from where the viewers are, and the map that arrives next simply does not
432
433
  // have this one in it. A name to release was the last place a viewer
@@ -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);
@@ -912,6 +942,19 @@ export class ViewerRequests {
912
942
  clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
913
943
  clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
914
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,
915
958
  sourceIds,
916
959
  processedSeconds: trackProgress?.processedSeconds,
917
960
  startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
@@ -970,9 +1013,8 @@ export class ViewerRequests {
970
1013
  bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
971
1014
  reserveSeconds,
972
1015
  lookaheadSeconds: this.#host.lookaheadSeconds,
973
- // What this proxy may keep of downloaded pieces on disk: the most a
974
- // start can hold of the film ahead of the viewer.
975
- sourceStorageBytes: this.#host.sourceStorageBytes?.() ?? null,
1016
+ // Disk room remaining after stock reserved for other active outputs.
1017
+ sourceStorageBytes: this.#host.sourceStorageBytes?.(session.outputKey ?? session.id) ?? null,
976
1018
  requiredAudio: requiresSeparateAudio,
977
1019
  sources: [...sourceMeasurements.values()],
978
1020
  tracks,
@@ -985,6 +1027,9 @@ export class ViewerRequests {
985
1027
  viewer.subtitle.fileIndex === session.file.fileIndex ? viewer.subtitle : null;
986
1028
  const startedAt = performance.now();
987
1029
  const predicted = this.#host.playbackReadiness.predict(input);
1030
+ if (consumerId && viewer && this.#host.viewers.get(consumerId) === viewer && Number.isFinite(predicted.cushionBytes)) {
1031
+ this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
1032
+ }
988
1033
  const predictMs = performance.now() - startedAt;
989
1034
  this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
990
1035
  logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
@@ -992,6 +1037,7 @@ export class ViewerRequests {
992
1037
  `start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
993
1038
  const forecast = withSubtitleReadiness(predicted, subtitle,
994
1039
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
1040
+ forecast.snapshotAtProxyMs = startedAt;
995
1041
  forecast.operations = outputs.flatMap((output, index) => {
996
1042
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
997
1043
  processedSeconds: tracks[index].processedSeconds,
@@ -1049,9 +1095,13 @@ export class ViewerRequests {
1049
1095
  const forecastInputKey = unavailableInput ? JSON.stringify([
1050
1096
  unavailableInput.sourceId, unavailableInput.trackId, unavailableInput.segmentIndex, unavailableInput.phase
1051
1097
  ]) : null;
1052
- if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey) {
1098
+ const browserEvidenceKey = browserServiceFingerprint(tracks);
1099
+ const browserEvidenceChanged = measurement.browserEvidenceKey !== browserEvidenceKey;
1100
+ if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey ||
1101
+ browserEvidenceChanged) {
1053
1102
  measurement.forecastReason = forecast.reason;
1054
1103
  measurement.forecastInputKey = forecastInputKey;
1104
+ measurement.browserEvidenceKey = browserEvidenceKey;
1055
1105
  if (forecast.subtitles) this.#host.observeWait?.(session.file.sourceKey, "process", {
1056
1106
  process: "C3", phase: "event", fileIndex: session.file.fileIndex,
1057
1107
  trackIndex: forecast.subtitles.trackIndex, ready: forecast.subtitles.ready,
@@ -1073,8 +1123,10 @@ export class ViewerRequests {
1073
1123
  downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
1074
1124
  downloadRateSummaries
1075
1125
  })),
1076
- tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
1077
- id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
1126
+ tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices, appendService, fragmentService }) => ({
1127
+ id, processedSeconds, readings, appendService: browserServiceSummary(appendService),
1128
+ fragmentService: browserServiceSummary(fragmentService), segments: segments.length,
1129
+ prepared: readySegmentIndices.length
1078
1130
  }))
1079
1131
  })}`);
1080
1132
  }
@@ -95,6 +95,7 @@ import { audioReadingFor } from "../encode/audio-work-rate.js";
95
95
  import { wireMachineBudget } from "../storage/wire.js";
96
96
  import { Returns } from "../storage/returns.js";
97
97
  import { freeBytesFor } from "../storage/free.js";
98
+ import { PlaybackStorageDemand } from "../storage/PlaybackStorageDemand.js";
98
99
  import { inputCacheRoot, probeInputCache } from "../encode/input-cache.js";
99
100
  import path from "node:path";
100
101
  import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
@@ -579,6 +580,7 @@ export function wireOutputs({
579
580
  get encodeRuns() { return parts.encodeRuns; },
580
581
  get keyframeTables() { return parts.keyframeTables; },
581
582
  get machineBudget() { return parts.machineBudget; },
583
+ releasePlaybackStorageDemand: (key, consumerId) => parts.playbackStorageDemand.release(key, consumerId),
582
584
  get outputTimes() { return parts.outputTimes; },
583
585
  get outputs() { return parts.outputs; },
584
586
  get returns() { return parts.returns; },
@@ -629,7 +631,13 @@ export function wireOutputs({
629
631
  viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
630
632
  minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
631
633
  // What this proxy may keep of downloaded pieces on disk.
632
- sourceStorageBytes: () => parts.machineBudget?.allowedFor("spilled pieces") ?? null,
634
+ sourceStorageBytes: outputKey => {
635
+ const capacity = parts.machineBudget?.storageBytes();
636
+ return Number.isFinite(capacity)
637
+ ? Math.max(0, capacity - parts.playbackStorageDemand.bytesExcept(outputKey))
638
+ : null;
639
+ },
640
+ notePlaybackStorageDemand: (key, consumerId, bytes) => parts.playbackStorageDemand.note(key, consumerId, bytes),
633
641
  getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
634
642
  disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
635
643
  planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
@@ -1173,6 +1181,11 @@ export function wireOutputs({
1173
1181
  // one term of the keeping period that is guessed rather than measured, and
1174
1182
  // the only place it can be measured from.
1175
1183
  parts.returns = new Returns();
1184
+ parts.playbackStorageDemand = new PlaybackStorageDemand(() => {
1185
+ if (parts.machineBudget) {
1186
+ void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
1187
+ }
1188
+ });
1176
1189
  parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
1177
1190
  resolve: resolveEncodeInput,
1178
1191
  readRanges: readEncodeRanges,
@@ -1221,6 +1234,7 @@ export function wireOutputs({
1221
1234
  // FFmpeg's cache keeps its file in /tmp, or where FFmpeg runs on a build
1222
1235
  // without `mkstemp` — an output directory of the segments.
1223
1236
  inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root: inputCacheRoot() },
1237
+ playbackStorage: parts.playbackStorageDemand,
1224
1238
  policy: parts.budgetPolicy ?? undefined,
1225
1239
  readFree: freeBytesFor,
1226
1240
  logger
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The bytes an active playback forecast says must remain available on disk.
3
+ * Viewers of one output share its stored bytes, so its demand is the largest
4
+ * live viewer demand; distinct outputs add their requirements.
5
+ */
6
+ export class PlaybackStorageDemand {
7
+ #byOutput = new Map();
8
+ #changed;
9
+
10
+ constructor(changed = () => {}) {
11
+ this.#changed = changed;
12
+ }
13
+
14
+ /** @returns {number} */
15
+ bytes() {
16
+ let total = 0;
17
+ for (const viewers of this.#byOutput.values()) {
18
+ let shared = 0;
19
+ for (const bytes of viewers.values()) shared = Math.max(shared, bytes);
20
+ total += shared;
21
+ }
22
+ return total;
23
+ }
24
+
25
+ /** @param {string} outputKey @returns {number} */
26
+ bytesExcept(outputKey) {
27
+ let total = 0;
28
+ for (const [key, viewers] of this.#byOutput) {
29
+ if (key === outputKey) continue;
30
+ let shared = 0;
31
+ for (const bytes of viewers.values()) shared = Math.max(shared, bytes);
32
+ total += shared;
33
+ }
34
+ return total;
35
+ }
36
+
37
+ /** @param {string} outputKey @param {string} consumerId @param {number} bytes */
38
+ note(outputKey, consumerId, bytes) {
39
+ if (!outputKey || !consumerId || !Number.isFinite(bytes) || bytes < 0) return;
40
+ const before = this.bytes();
41
+ let viewers = this.#byOutput.get(outputKey);
42
+ if (!viewers) {
43
+ viewers = new Map();
44
+ this.#byOutput.set(outputKey, viewers);
45
+ }
46
+ viewers.set(consumerId, bytes);
47
+ this.#notifyIfChanged(before);
48
+ }
49
+
50
+ /** @param {string} outputKey @param {string} consumerId */
51
+ release(outputKey, consumerId) {
52
+ const viewers = this.#byOutput.get(outputKey);
53
+ if (!viewers?.has(consumerId)) return;
54
+ const before = this.bytes();
55
+ viewers.delete(consumerId);
56
+ if (viewers.size === 0) this.#byOutput.delete(outputKey);
57
+ this.#notifyIfChanged(before);
58
+ }
59
+
60
+ #notifyIfChanged(before) {
61
+ const after = this.bytes();
62
+ if (after !== before) this.#changed(after);
63
+ }
64
+ }
@@ -10,7 +10,9 @@
10
10
  * segments an encoder produced, the files downloaded whole — and the evidence,
11
11
  * which is a claimant with a rule of its own (`Diagnostics.js`). Three of the
12
12
  * five live on the torrent thread, so they arrive as pairs of closures over its
13
- * channel rather than as the objects themselves.
13
+ * channel rather than as the objects themselves. Active playback also
14
+ * reserves its forecasted source stock here; that is a requirement, not a
15
+ * store holding bytes yet.
14
16
  *
15
17
  * **Which RESOURCE each takes is read, not assumed.** Memory is one. Disk is
16
18
  * one per device, because two filesystems cannot pay for each other: measured
@@ -56,7 +58,7 @@ import { availableMemoryBytes } from "./machine-memory.js";
56
58
  * @param {import("./MachineBudget.js").BudgetPolicy} [params.policy] - What the
57
59
  * operator has said this proxy may take. Absent, the measured default.
58
60
  * @param {{ info: Function, warn?: Function }} [params.logger]
59
- * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number | null, diagnostics: object | null, describe: () => string }}
61
+ * @returns {{ revise: (resource?: string | null, because?: string | null) => Promise<unknown>, segmentBytes: () => number | null, storageBytes: () => number | null, storageResource: string, diagnostics: object | null, describe: () => string }}
60
62
  * What the segments may hold is asked for rather than pushed: null until the
61
63
  * first revision, then the measured allowance, including a measured zero.
62
64
  */
@@ -68,6 +70,7 @@ export function wireMachineBudget({
68
70
  indexMemory,
69
71
  wholeFiles,
70
72
  inputCaches,
73
+ playbackStorage,
71
74
  diagnostics,
72
75
  diagnosticsRoot = "",
73
76
  readFree,
@@ -177,6 +180,17 @@ export function wireMachineBudget({
177
180
  });
178
181
  }
179
182
 
183
+ if (typeof playbackStorage?.bytes === "function") {
184
+ budget.register({
185
+ name: "playback stock",
186
+ resource: segmentsOn,
187
+ held: () => 0,
188
+ wanted: () => playbackStorage.bytes(),
189
+ required: () => playbackStorage.bytes(),
190
+ allow: () => {}
191
+ });
192
+ }
193
+
180
194
  let evidenceOn = null;
181
195
  if (diagnostics) {
182
196
  evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
@@ -194,6 +208,8 @@ export function wireMachineBudget({
194
208
 
195
209
  return {
196
210
  capacityOf: name => budget.capacityOf(name),
211
+ storageBytes: () => budget.capacityOf(segmentsOn),
212
+ storageResource: segmentsOn,
197
213
  /** What one claimant was last allowed, or null before a measured division. */
198
214
  allowedFor: name => budget.allowedFor(name),
199
215
  /**
@@ -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)
@@ -735,16 +841,19 @@ export function predictPlaybackReadiness(input = {}) {
735
841
  // THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
736
842
  // source comes slower than the film plays means holding, at the start, what
737
843
  // is already here and everything that arrives before it. Where that is more
738
- // than this proxy may keep of downloaded pieces, the oldest of it would be
739
- // given up before the viewer reached it, and no wait makes the start safe:
844
+ // than the disk room left after other active playback stock is reserved,
845
+ // some of it would be given up before the viewer reached it, so no wait
846
+ // makes the start safe:
740
847
  // Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
741
848
  // share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
742
849
  // the viewer keeps waiting rather than starting into stops.
743
850
  const storageBytes = Number(input.sourceStorageBytes);
744
851
  const tailParts = tailStock.filter(part => !part.skipped);
852
+ const cushionBytes = Number.isFinite(delay) && tailParts.length > 0
853
+ ? tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
854
+ Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0)
855
+ : null;
745
856
  if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailParts.length > 0) {
746
- const cushionBytes = tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
747
- Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
748
857
  if (cushionBytes > storageBytes) {
749
858
  return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
750
859
  cushionBytes, storageBytes, tail: tailStock };
@@ -756,6 +865,7 @@ export function predictPlaybackReadiness(input = {}) {
756
865
  Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
757
866
  Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
758
867
  return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
868
+ ...(cushionBytes === null ? {} : { cushionBytes }),
759
869
  firstRequiredSegmentsSeconds,
760
870
  firstRequiredStages,
761
871
  // What the rest of the film weighs per source, at what rate it comes and