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