@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 +1 -1
- package/routes/api/sources/stats/get.js +10 -1
- package/routes/api/transcode-sessions/net-report/post.js +11 -1
- package/routes/api/transcode-sessions/progress/get.js +7 -0
- package/services/server/ViewerRequests.js +53 -3
- package/services/viewer/Viewer.js +130 -0
- package/services/viewer/playback-readiness.js +111 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
729
|
-
|
|
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)
|