@torrent-tv/proxy 2.107.47 → 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.47",
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": {
@@ -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
  }
@@ -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,
@@ -992,6 +1035,7 @@ export class ViewerRequests {
992
1035
  `start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
993
1036
  const forecast = withSubtitleReadiness(predicted, subtitle,
994
1037
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
1038
+ forecast.snapshotAtProxyMs = startedAt;
995
1039
  forecast.operations = outputs.flatMap((output, index) => {
996
1040
  const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
997
1041
  processedSeconds: tracks[index].processedSeconds,
@@ -1049,9 +1093,13 @@ export class ViewerRequests {
1049
1093
  const forecastInputKey = unavailableInput ? JSON.stringify([
1050
1094
  unavailableInput.sourceId, unavailableInput.trackId, unavailableInput.segmentIndex, unavailableInput.phase
1051
1095
  ]) : null;
1052
- 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) {
1053
1100
  measurement.forecastReason = forecast.reason;
1054
1101
  measurement.forecastInputKey = forecastInputKey;
1102
+ measurement.browserEvidenceKey = browserEvidenceKey;
1055
1103
  if (forecast.subtitles) this.#host.observeWait?.(session.file.sourceKey, "process", {
1056
1104
  process: "C3", phase: "event", fileIndex: session.file.fileIndex,
1057
1105
  trackIndex: forecast.subtitles.trackIndex, ready: forecast.subtitles.ready,
@@ -1073,8 +1121,10 @@ export class ViewerRequests {
1073
1121
  downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
1074
1122
  downloadRateSummaries
1075
1123
  })),
1076
- tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
1077
- 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
1078
1128
  }))
1079
1129
  })}`);
1080
1130
  }
@@ -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)