@torrent-tv/proxy 2.107.53 → 2.107.54
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 +4 -4
- package/routes/api/transcode-sessions/net-report/post.js +18 -0
- package/routes/api/transcode-sessions/progress/get.js +9 -1
- package/services/encode/EncodeRun.js +13 -3
- package/services/encode/RunProgress.js +11 -2
- package/services/encode/SpeedReadings.js +10 -2
- package/services/server/ViewerRequests.js +169 -43
- package/services/torrent/worker/worker.js +27 -3
- package/services/viewer/Viewer.js +117 -17
- package/services/viewer/ViewerPlaybackState.js +25 -7
- package/services/viewer/playback-readiness.js +25 -3
package/package.json
CHANGED
|
@@ -70,12 +70,10 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
70
70
|
// local lookup. Without the await the reply was the pending promise itself,
|
|
71
71
|
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
72
72
|
const statsStartedAt = performance.now();
|
|
73
|
-
let statsCompletedAt;
|
|
74
73
|
let stats;
|
|
75
74
|
try {
|
|
76
75
|
stats = await torrentPool.getFileStats(torrent, fileIndex);
|
|
77
76
|
} finally {
|
|
78
|
-
statsCompletedAt = performance.now();
|
|
79
77
|
logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
|
|
80
78
|
}
|
|
81
79
|
if (stats?.pending || !stats) {
|
|
@@ -161,9 +159,11 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
161
159
|
}
|
|
162
160
|
const responsePreparedAtProxyMs = performance.now();
|
|
163
161
|
return reply.send({ ...stats, sourceSnapshotTransport: {
|
|
162
|
+
clockEpoch: performance.timeOrigin,
|
|
163
|
+
snapshotClockEpoch: stats.sourceCapture?.clockEpoch ?? null,
|
|
164
164
|
requestReceivedAtProxyMs,
|
|
165
|
-
snapshotStartedAtProxyMs:
|
|
166
|
-
snapshotCompletedAtProxyMs:
|
|
165
|
+
snapshotStartedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.startedAt : null,
|
|
166
|
+
snapshotCompletedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.completedAt : null,
|
|
167
167
|
responsePreparedAtProxyMs
|
|
168
168
|
} });
|
|
169
169
|
}
|
|
@@ -117,12 +117,30 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
|
117
117
|
linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
|
|
118
118
|
linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
|
|
119
119
|
linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
|
|
120
|
+
linkSampleAgeMs: Number.isFinite(body.linkSampleAgeMs) && body.linkSampleAgeMs >= 0
|
|
121
|
+
? body.linkSampleAgeMs : undefined,
|
|
120
122
|
appendSessionId: sessionId,
|
|
121
123
|
appendEpoch: typeof body.appendEpoch === "string" ? body.appendEpoch : undefined,
|
|
122
124
|
appendEpochStartedAt: Number.isFinite(body.appendEpochStartedAt) && body.appendEpochStartedAt > 0
|
|
123
125
|
? body.appendEpochStartedAt : undefined,
|
|
124
126
|
appendSnapshotAt: Number.isFinite(body.appendSnapshotAt) && body.appendSnapshotAt > 0
|
|
125
127
|
? body.appendSnapshotAt : undefined,
|
|
128
|
+
captureClockEpoch: Number.isFinite(body.captureClockEpoch) ? body.captureClockEpoch : undefined,
|
|
129
|
+
captureEpoch: typeof body.captureEpoch === "string" ? body.captureEpoch : undefined,
|
|
130
|
+
captureEpochStartedAtBrowser: Number.isFinite(body.captureEpochStartedAtBrowser)
|
|
131
|
+
? body.captureEpochStartedAtBrowser : undefined,
|
|
132
|
+
captureRevision: Number.isSafeInteger(body.captureRevision) ? body.captureRevision : undefined,
|
|
133
|
+
captureStartedAtBrowser: Number.isFinite(body.captureStartedAtBrowser) ? body.captureStartedAtBrowser : undefined,
|
|
134
|
+
captureCompletedAtBrowser: Number.isFinite(body.captureCompletedAtBrowser)
|
|
135
|
+
? body.captureCompletedAtBrowser : undefined,
|
|
136
|
+
linkSampleStartedAtBrowser: Number.isFinite(body.linkSampleStartedAtBrowser)
|
|
137
|
+
? body.linkSampleStartedAtBrowser : undefined,
|
|
138
|
+
linkSampleCompletedAtBrowser: Number.isFinite(body.linkSampleCompletedAtBrowser)
|
|
139
|
+
? body.linkSampleCompletedAtBrowser : undefined,
|
|
140
|
+
linkSampleActionId: typeof body.linkSampleActionId === "string" ? body.linkSampleActionId : undefined,
|
|
141
|
+
linkSampleGeneration: Number.isSafeInteger(body.linkSampleGeneration) ? body.linkSampleGeneration : undefined,
|
|
142
|
+
linkSampleClockEpoch: Number.isFinite(body.linkSampleClockEpoch) ? body.linkSampleClockEpoch : undefined,
|
|
143
|
+
linkSampleRevision: Number.isSafeInteger(body.linkSampleRevision) ? body.linkSampleRevision : undefined,
|
|
126
144
|
appendService: body.appendService && typeof body.appendService === "object" ? body.appendService : undefined,
|
|
127
145
|
fragmentService: body.fragmentService && typeof body.fragmentService === "object" ? body.fragmentService : undefined,
|
|
128
146
|
bufferedAheadSec,
|
|
@@ -19,13 +19,21 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewer
|
|
|
19
19
|
// quality change the stream on screen is another session — a different one
|
|
20
20
|
// for each viewer who changed.
|
|
21
21
|
const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
|
|
22
|
-
const
|
|
22
|
+
const stated = Number(req.query?.generation);
|
|
23
|
+
const generation = Number.isSafeInteger(stated) && stated >= 0 ? stated : Number.NaN;
|
|
24
|
+
const progress = await viewerRequests.getSessionProgress(sessionId, consumerId, generation);
|
|
25
|
+
if (progress?.superseded === true) {
|
|
26
|
+
return reply.code(409).send({ error: "Viewer action was superseded." });
|
|
27
|
+
}
|
|
23
28
|
if (!progress) {
|
|
24
29
|
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
const responsePreparedAtProxyMs = performance.now();
|
|
28
33
|
progress.snapshotTransport = {
|
|
34
|
+
clockEpoch: performance.timeOrigin,
|
|
35
|
+
snapshotClockEpoch: progress.playbackReadiness?.snapshotClockEpoch ?? null,
|
|
36
|
+
generation: Number.isSafeInteger(generation) ? generation : null,
|
|
29
37
|
requestReceivedAtProxyMs,
|
|
30
38
|
snapshotAtProxyMs: progress.playbackReadiness?.snapshotAtProxyMs ?? null,
|
|
31
39
|
responsePreparedAtProxyMs
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* owns the output. This carries a decision out and reports what happened.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { performance } from "node:perf_hooks";
|
|
41
|
+
|
|
40
42
|
import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
|
|
41
43
|
import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
|
|
42
44
|
import { workDeltaBetween } from "./encoder-readings.js";
|
|
@@ -228,6 +230,7 @@ export class EncodeRun {
|
|
|
228
230
|
* @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
|
|
229
231
|
* @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
|
|
230
232
|
* @param {() => number} [params.now]
|
|
233
|
+
* @param {() => number} [params.monotonicNow]
|
|
231
234
|
* @param {(ended: RunEnded) => void} [params.onEnded]
|
|
232
235
|
* @param {(reading: { speed: number, at: number, workSeconds: number, workingSeconds: number }) => void} [params.onSpeedMeasured] -
|
|
233
236
|
* Told each time this run has measured its processing speed, once per
|
|
@@ -291,6 +294,7 @@ export class EncodeRun {
|
|
|
291
294
|
indexOfName,
|
|
292
295
|
startSeconds = 0,
|
|
293
296
|
totalSeconds = null,
|
|
297
|
+
monotonicNow,
|
|
294
298
|
because = "no reason was given"
|
|
295
299
|
}) {
|
|
296
300
|
this.address = address;
|
|
@@ -302,10 +306,11 @@ export class EncodeRun {
|
|
|
302
306
|
this.spawnProcess = spawn;
|
|
303
307
|
this.logger = logger;
|
|
304
308
|
this.now = typeof now === "function" ? now : Date.now;
|
|
305
|
-
this.
|
|
309
|
+
this.monotonicNow = typeof monotonicNow === "function" ? monotonicNow : () => performance.now();
|
|
310
|
+
this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now, monotonicNow: this.monotonicNow });
|
|
306
311
|
/** Where this run's time went: its own work, or waiting, or stopped. */
|
|
307
312
|
this.clock = new RunClock({ now: this.now });
|
|
308
|
-
this.#pieceSample = { at: this.now(), producedSeconds: 0, workingMs: 0 };
|
|
313
|
+
this.#pieceSample = { at: this.now(), monotonicAt: this.monotonicNow(), producedSeconds: 0, workingMs: 0 };
|
|
309
314
|
this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
|
|
310
315
|
this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
|
|
311
316
|
this.onProduced = typeof onProduced === "function" ? onProduced : () => {};
|
|
@@ -463,6 +468,7 @@ export class EncodeRun {
|
|
|
463
468
|
this.#pieceSample = {
|
|
464
469
|
...this.#workSample,
|
|
465
470
|
at: this.now(),
|
|
471
|
+
monotonicAt: this.monotonicNow(),
|
|
466
472
|
workingMs: this.clock.workingMs()
|
|
467
473
|
};
|
|
468
474
|
}
|
|
@@ -600,6 +606,7 @@ export class EncodeRun {
|
|
|
600
606
|
const at = this.now();
|
|
601
607
|
this.#workSample = {
|
|
602
608
|
at,
|
|
609
|
+
monotonicAt: this.progress.updatedAtMonotonic,
|
|
603
610
|
producedSeconds: Math.max(0, this.progress.processedSeconds - this.progress.startPositionSeconds),
|
|
604
611
|
workingMs: this.clock.workingMs(at)
|
|
605
612
|
};
|
|
@@ -766,7 +773,10 @@ export class EncodeRun {
|
|
|
766
773
|
}
|
|
767
774
|
const work = workDeltaBetween(this.#pieceSample, sample);
|
|
768
775
|
if (work !== null) {
|
|
769
|
-
this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work
|
|
776
|
+
this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work,
|
|
777
|
+
captureOwner: "proxy-encoder", captureClock: "performance", captureClockEpoch: performance.timeOrigin,
|
|
778
|
+
captureStartedAtMonotonic: this.#pieceSample.monotonicAt,
|
|
779
|
+
captureCompletedAtMonotonic: sample.monotonicAt };
|
|
770
780
|
this.#pieceSample = sample;
|
|
771
781
|
this.#speedMeasurementPending = false;
|
|
772
782
|
this.onSpeedMeasured(this.speedReading);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @file Progress reported by one encoder run, placed on the source timeline.
|
|
3
5
|
*
|
|
@@ -22,15 +24,18 @@ function parseTimestamp(value) {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export class RunProgress {
|
|
25
|
-
constructor({ startSeconds = 0, totalSeconds = null, now = Date.now } = {}) {
|
|
27
|
+
constructor({ startSeconds = 0, totalSeconds = null, now = Date.now, monotonicNow = () => performance.now() } = {}) {
|
|
26
28
|
this.startPositionSeconds = Number.isFinite(startSeconds) ? Math.max(0, startSeconds) : 0;
|
|
27
29
|
this.processedSeconds = this.startPositionSeconds;
|
|
28
30
|
this.totalSeconds = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : null;
|
|
29
31
|
this.percent = null;
|
|
30
32
|
this.remainingSeconds = this.totalSeconds;
|
|
31
33
|
this.updatedAt = now();
|
|
34
|
+
this.updatedAtMonotonic = monotonicNow();
|
|
35
|
+
this.clockEpoch = performance.timeOrigin;
|
|
32
36
|
this.lastLoggedAt = 0;
|
|
33
37
|
this.now = now;
|
|
38
|
+
this.monotonicNow = monotonicNow;
|
|
34
39
|
this.#derive();
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -42,6 +47,7 @@ export class RunProgress {
|
|
|
42
47
|
this.processedSeconds = this.startPositionSeconds + Math.max(0, relative);
|
|
43
48
|
}
|
|
44
49
|
this.updatedAt = this.now();
|
|
50
|
+
this.updatedAtMonotonic = this.monotonicNow();
|
|
45
51
|
this.#derive();
|
|
46
52
|
return this.snapshot();
|
|
47
53
|
}
|
|
@@ -61,7 +67,9 @@ export class RunProgress {
|
|
|
61
67
|
totalSeconds: this.totalSeconds,
|
|
62
68
|
percent: this.percent,
|
|
63
69
|
remainingSeconds: this.remainingSeconds,
|
|
64
|
-
updatedAt: this.updatedAt
|
|
70
|
+
updatedAt: this.updatedAt,
|
|
71
|
+
updatedAtMonotonic: this.updatedAtMonotonic,
|
|
72
|
+
clockEpoch: this.clockEpoch
|
|
65
73
|
};
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -77,3 +85,4 @@ export class RunProgress {
|
|
|
77
85
|
this.remainingSeconds = Math.max(0, this.totalSeconds - this.processedSeconds);
|
|
78
86
|
}
|
|
79
87
|
}
|
|
88
|
+
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export class SpeedReadings {
|
|
17
|
-
/** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number
|
|
17
|
+
/** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
|
|
18
|
+
* captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
|
|
19
|
+
* captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number }>>} */
|
|
18
20
|
#byOutput = new WeakMap();
|
|
19
21
|
#keepMs;
|
|
20
22
|
|
|
@@ -25,7 +27,9 @@ export class SpeedReadings {
|
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* @param {object} output
|
|
28
|
-
* @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number
|
|
30
|
+
* @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
|
|
31
|
+
* captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
|
|
32
|
+
* captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number } | null} reading
|
|
29
33
|
*/
|
|
30
34
|
note(output, reading) {
|
|
31
35
|
if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
|
|
@@ -34,6 +38,10 @@ export class SpeedReadings {
|
|
|
34
38
|
let place = readings.length;
|
|
35
39
|
while (place > 0 && readings[place - 1].at > reading.at) place -= 1;
|
|
36
40
|
const sample = { speed: reading.speed, at: reading.at };
|
|
41
|
+
for (const key of ["captureOwner", "captureClock", "captureClockEpoch", "captureStartedAtMonotonic",
|
|
42
|
+
"captureCompletedAtMonotonic"]) {
|
|
43
|
+
if (reading[key] !== undefined) sample[key] = reading[key];
|
|
44
|
+
}
|
|
37
45
|
if (Number.isFinite(reading.workSeconds) && reading.workSeconds > 0 &&
|
|
38
46
|
Number.isFinite(reading.workingSeconds) && reading.workingSeconds > 0) {
|
|
39
47
|
Object.assign(sample, { workSeconds: reading.workSeconds, workingSeconds: reading.workingSeconds });
|
|
@@ -87,6 +87,49 @@ function forecastInputIdentity(state) {
|
|
|
87
87
|
return state && JSON.stringify([state.sourceId, state.trackId, state.segmentIndex, state.phase]);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function unknownCapture(owner, actionId, generation, reason) {
|
|
91
|
+
return { owner, clock: "performance", clockEpoch: null, startedAt: null, completedAt: null,
|
|
92
|
+
actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null,
|
|
93
|
+
revision: null, validity: "unknown", unknownReason: reason };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sourceCaptureOf(stats, actionId, generation) {
|
|
97
|
+
const source = stats?.sourceCapture;
|
|
98
|
+
if (!source || source.owner !== "torrent-worker" || source.clock !== "performance" ||
|
|
99
|
+
source.validity !== "valid" || !Number.isFinite(source.clockEpoch) ||
|
|
100
|
+
source.clockEpoch !== performance.timeOrigin ||
|
|
101
|
+
!Number.isFinite(source.startedAt) || !Number.isFinite(source.completedAt) ||
|
|
102
|
+
source.completedAt < source.startedAt || !Number.isSafeInteger(source.revision)) {
|
|
103
|
+
return { ...unknownCapture("torrent-worker", actionId, generation,
|
|
104
|
+
source?.unknownReason ?? "source-capture-unavailable"),
|
|
105
|
+
...(Number.isFinite(source?.clockEpoch) ? { clockEpoch: source.clockEpoch } : {}),
|
|
106
|
+
...(Number.isSafeInteger(source?.revision) ? { revision: source.revision } : {}) };
|
|
107
|
+
}
|
|
108
|
+
return { ...source, actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
|
|
112
|
+
const runCaptures = (liveRuns ?? []).map(run => ({ owner: "proxy-encoder", clock: "performance",
|
|
113
|
+
clockEpoch: run.progress?.clockEpoch, startedAt: run.progress?.updatedAtMonotonic,
|
|
114
|
+
completedAt: run.progress?.updatedAtMonotonic, revision: run.progress?.updatedAtMonotonic,
|
|
115
|
+
validity: Number.isFinite(run.progress?.clockEpoch) && Number.isFinite(run.progress?.updatedAtMonotonic)
|
|
116
|
+
? "valid" : "unknown", unknownReason: Number.isFinite(run.progress?.clockEpoch) &&
|
|
117
|
+
Number.isFinite(run.progress?.updatedAtMonotonic) ? null : "encoder-progress-capture-unavailable" }));
|
|
118
|
+
const captures = [...(Array.isArray(sampleCaptures) ? sampleCaptures : []), ...runCaptures];
|
|
119
|
+
const epoch = captures[0]?.clockEpoch;
|
|
120
|
+
const valid = captures.length > 0 && epoch === performance.timeOrigin && captures.every(capture =>
|
|
121
|
+
capture.owner === "proxy-encoder" && capture.clock === "performance" &&
|
|
122
|
+
Number.isFinite(capture.clockEpoch) && capture.clockEpoch === epoch && Number.isFinite(capture.startedAt) &&
|
|
123
|
+
Number.isFinite(capture.completedAt) && capture.completedAt >= capture.startedAt &&
|
|
124
|
+
Number.isFinite(capture.revision));
|
|
125
|
+
if (!valid) return unknownCapture("proxy-encoder", actionId, generation, "encoder-capture-unavailable");
|
|
126
|
+
return { owner: "proxy-encoder", clock: "performance", clockEpoch: epoch,
|
|
127
|
+
startedAt: Math.min(...captures.map(capture => capture.startedAt)),
|
|
128
|
+
completedAt: Math.max(...captures.map(capture => capture.completedAt)), actionId: actionId ?? null,
|
|
129
|
+
generation: Number.isSafeInteger(generation) ? generation : null,
|
|
130
|
+
revision: captures.at(-1).revision ?? null, samples: captures, validity: "valid", unknownReason: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
90
133
|
function observeForecastInput(observeWait, sourceKey, state, event) {
|
|
91
134
|
if (!state) return;
|
|
92
135
|
observeWait?.(sourceKey, "process", {
|
|
@@ -612,7 +655,7 @@ export class ViewerRequests {
|
|
|
612
655
|
return this.#host.viewerSecondsOn(session, consumerId);
|
|
613
656
|
}
|
|
614
657
|
|
|
615
|
-
async getSessionProgress(sessionId, consumerId = "") {
|
|
658
|
+
async getSessionProgress(sessionId, consumerId = "", statedGeneration = Number.NaN) {
|
|
616
659
|
if (!isOutputName(sessionId)) {
|
|
617
660
|
return null;
|
|
618
661
|
}
|
|
@@ -628,6 +671,12 @@ export class ViewerRequests {
|
|
|
628
671
|
const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
|
|
629
672
|
this.#host.outputs.touch(session);
|
|
630
673
|
const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
674
|
+
const requestGeneration = consumerId && viewer?.assignments
|
|
675
|
+
? this.#host.generationOfRequest(consumerId) : null;
|
|
676
|
+
const requestGenerationIsStated = Number.isSafeInteger(statedGeneration);
|
|
677
|
+
if (requestGenerationIsStated && requestGeneration !== statedGeneration) return { superseded: true };
|
|
678
|
+
const requestGenerationIsCurrent = () => !Number.isSafeInteger(requestGeneration) ||
|
|
679
|
+
this.#host.generationOfRequest(consumerId) === requestGeneration;
|
|
631
680
|
const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
|
|
632
681
|
const audio = this.#host.renditions.servesAudioSeparately(session)
|
|
633
682
|
? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
|
|
@@ -648,10 +697,12 @@ export class ViewerRequests {
|
|
|
648
697
|
// Observed output bitrate (Mbit/s) from recently completed segments. The
|
|
649
698
|
// proxy uses it for the viewer-link budget and the readiness forecast.
|
|
650
699
|
const outputMbps = await this.#host.quality.observedStreamMbps(session);
|
|
700
|
+
if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
|
|
651
701
|
if (!stillOwnsProgress()) return null;
|
|
652
702
|
const progress = this.#host.encodeRuns.progressOf(session);
|
|
653
703
|
const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
|
|
654
704
|
viewer, stillOwnsProgress);
|
|
705
|
+
if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
|
|
655
706
|
if (!stillOwnsProgress()) return null;
|
|
656
707
|
return {
|
|
657
708
|
// The id the caller asked about, not the variant it was answered from —
|
|
@@ -758,13 +809,31 @@ export class ViewerRequests {
|
|
|
758
809
|
// the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
|
|
759
810
|
const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
|
|
760
811
|
const viewerReading = viewer?.linkReading() ?? null;
|
|
812
|
+
const generation = consumerId && viewer?.assignments
|
|
813
|
+
? this.#host.generationOfRequest(consumerId) : null;
|
|
814
|
+
const actionIsCurrent = () => !Number.isSafeInteger(generation) ||
|
|
815
|
+
this.#host.generationOfRequest(consumerId) === generation;
|
|
816
|
+
const actionId = Number.isSafeInteger(generation)
|
|
817
|
+
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
761
818
|
const measurement = viewer
|
|
762
819
|
? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
|
|
763
820
|
: { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
|
|
821
|
+
const existingLinkCaptures = measurement.link.captureSnapshot();
|
|
822
|
+
if (viewer && (viewerReading?.linkCapture?.validity !== "valid" || existingLinkCaptures.some(capture =>
|
|
823
|
+
!capture || capture.clockEpoch !== viewerReading.linkCapture.clockEpoch))) {
|
|
824
|
+
this.#host.viewerPlaybackState.forgetLinkReadings(measurement, this.#host.playbackReadiness,
|
|
825
|
+
this.#host.lookaheadSeconds);
|
|
826
|
+
}
|
|
764
827
|
const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
|
|
765
|
-
if (
|
|
766
|
-
|
|
828
|
+
if (viewerReading?.linkCapture?.validity === "valid" && Number.isFinite(viewerReading?.linkSampleMbps) &&
|
|
829
|
+
viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
|
|
830
|
+
this.#host.viewerPlaybackState.addLinkReading(measurement, linkAt, viewerReading.linkSampleMbps,
|
|
831
|
+
viewerReading.linkCapture);
|
|
767
832
|
}
|
|
833
|
+
const rawBufferCapture = viewerReading?.bufferCapture ?? unknownCapture("browser", actionId, generation,
|
|
834
|
+
"browser-buffer-capture-unavailable");
|
|
835
|
+
const bufferCaptureValid = rawBufferCapture.validity === "valid" && rawBufferCapture.actionId === session.id &&
|
|
836
|
+
rawBufferCapture.generation === generation;
|
|
768
837
|
|
|
769
838
|
const outputs = [session];
|
|
770
839
|
const requiresSeparateAudio = this.#host.renditions.servesAudioSeparately(session);
|
|
@@ -780,21 +849,24 @@ export class ViewerRequests {
|
|
|
780
849
|
let primarySourceStats = null;
|
|
781
850
|
const tracks = [];
|
|
782
851
|
for (const output of outputs) {
|
|
783
|
-
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
852
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
784
853
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
785
|
-
const
|
|
854
|
+
const trackRate = this.#trackRateReadings(output, now);
|
|
855
|
+
const trackRates = trackRate?.summary ?? null;
|
|
786
856
|
const trackName = output === session ? "video" : "audio";
|
|
787
857
|
// The page states the offset its player applied to this track, and the
|
|
788
858
|
// init it applied it to is the session's. Until both are known, the
|
|
789
859
|
// pieces stay on their own presentation timeline: there is nothing yet
|
|
790
860
|
// to place them by.
|
|
791
|
-
const playerOffset = viewerReading?.timestampOffsets?.[trackName];
|
|
861
|
+
const playerOffset = bufferCaptureValid ? viewerReading?.timestampOffsets?.[trackName] : null;
|
|
862
|
+
const reportedPosition = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
|
|
863
|
+
? viewerReading.positionSeconds : null;
|
|
792
864
|
const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
|
|
793
865
|
const placedByPlayer = Boolean(servedInit?.length);
|
|
794
866
|
const sessionInit = placedByPlayer ? servedInit : null;
|
|
795
867
|
const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
|
|
796
|
-
const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
|
|
797
|
-
?
|
|
868
|
+
const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
|
|
869
|
+
Number.isFinite(reportedPosition) ? reportedPosition : this.#host.viewerSecondsOn(session, consumerId, now));
|
|
798
870
|
const sourceIndexes = [...output.spec.sourceFileIndexes];
|
|
799
871
|
if (sourceIndexes.length === 0) {
|
|
800
872
|
sourceIndexes.push(output.file.fileIndex);
|
|
@@ -814,36 +886,62 @@ export class ViewerRequests {
|
|
|
814
886
|
} catch {
|
|
815
887
|
stats = null;
|
|
816
888
|
}
|
|
817
|
-
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
889
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
818
890
|
if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
|
|
819
891
|
parts.statsMs += performance.now() - statsAt;
|
|
820
892
|
parts.workerForecastMs += Number(stats?.forecastMs) || 0;
|
|
893
|
+
const sourceCapture = sourceCaptureOf(stats, actionId, generation);
|
|
821
894
|
if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
|
|
822
895
|
const sharedForecast = sourceForecasts.get(output.file.sourceKey);
|
|
823
|
-
const
|
|
896
|
+
const capturedForecast = sourceCaptureOf({ sourceCapture: sharedForecast?.capture }, actionId, generation);
|
|
897
|
+
const forecastCapture = sharedForecast?.global === true && !Array.isArray(sharedForecast.ranges)
|
|
898
|
+
? { ...capturedForecast, validity: "unknown", unknownReason: "source-arrival-ranges-unavailable" }
|
|
899
|
+
: capturedForecast;
|
|
900
|
+
const downloadForecast = sourceCapture.validity !== "valid" || forecastCapture.validity !== "valid"
|
|
901
|
+
? { ranges: [] } : sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
|
|
824
902
|
ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
|
|
825
903
|
start: Math.max(0, range.start - stats.fileOffset),
|
|
826
904
|
end: Math.min(stats.fileLength, range.end - stats.fileOffset)
|
|
827
905
|
})).filter(range => range.end > range.start) } : sharedForecast;
|
|
906
|
+
const sourceFactsValid = sourceCapture.validity === "valid";
|
|
907
|
+
const ownsSourceState = viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement);
|
|
908
|
+
const sourceReadingsValid = sourceFactsValid && Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0;
|
|
909
|
+
const existingSourceCaptures = ownsSourceState
|
|
910
|
+
? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
|
|
911
|
+
const sourceEpochChanged = sourceReadingsValid && existingSourceCaptures &&
|
|
912
|
+
[existingSourceCaptures.recent?.delivered, existingSourceCaptures.recent?.free,
|
|
913
|
+
existingSourceCaptures.tail?.delivered, existingSourceCaptures.tail?.free].some(capture =>
|
|
914
|
+
capture?.validity === "valid" && capture.clockEpoch !== sourceCapture.clockEpoch);
|
|
915
|
+
if (ownsSourceState && (!sourceReadingsValid || sourceEpochChanged)) {
|
|
916
|
+
this.#host.viewerPlaybackState.forgetSourceReadings(measurement, output.file.sourceKey);
|
|
917
|
+
}
|
|
918
|
+
const downloadRateReadings = ownsSourceState && sourceReadingsValid
|
|
919
|
+
? this.#host.viewerPlaybackState.sourceRateReadings(measurement, output.file.sourceKey, stats, now,
|
|
920
|
+
this.#host.playbackReadiness, this.#host.lookaheadSeconds, sourceCapture) : null;
|
|
921
|
+
const tailRateReadings = ownsSourceState && sourceReadingsValid
|
|
922
|
+
? this.#host.viewerPlaybackState.sourceTailRateReadings(measurement, output.file.sourceKey, now,
|
|
923
|
+
this.#host.playbackReadiness) : null;
|
|
924
|
+
const downloadRateSummaries = ownsSourceState && sourceReadingsValid
|
|
925
|
+
? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null;
|
|
926
|
+
const rateCapture = ownsSourceState && sourceReadingsValid
|
|
927
|
+
? this.#host.viewerPlaybackState.sourceCaptureSnapshot(measurement, output.file.sourceKey) : null;
|
|
828
928
|
sourceMeasurements.set(sourceId, {
|
|
829
929
|
id: sourceId,
|
|
830
930
|
serviceKey: output.file.sourceKey,
|
|
831
|
-
complete: stats?.fileAvailable === true,
|
|
832
|
-
residence: stats?.residence,
|
|
833
|
-
fileOffset: stats?.fileOffset,
|
|
834
|
-
fileLength: stats?.fileLength,
|
|
835
|
-
bytesPerMediaSecond: Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
|
|
931
|
+
complete: sourceFactsValid ? stats?.fileAvailable === true : null,
|
|
932
|
+
residence: sourceFactsValid ? stats?.residence : null,
|
|
933
|
+
fileOffset: sourceFactsValid ? stats?.fileOffset : null,
|
|
934
|
+
fileLength: sourceFactsValid ? stats?.fileLength : null,
|
|
935
|
+
bytesPerMediaSecond: sourceFactsValid && Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
|
|
836
936
|
Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
|
|
837
937
|
? stats.fileLength / session.file.durationSeconds : null,
|
|
838
|
-
pieceLength: stats?.pieceLength,
|
|
839
|
-
downloadRateReadings
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
downloadRateSummaries: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
|
|
846
|
-
? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null,
|
|
938
|
+
pieceLength: sourceFactsValid ? stats?.pieceLength : null,
|
|
939
|
+
downloadRateReadings,
|
|
940
|
+
tailRateReadings,
|
|
941
|
+
downloadRateSummaries,
|
|
942
|
+
capture: { ...sourceCapture, rateReadings: rateCapture ?? {
|
|
943
|
+
validity: "unknown", unknownReason: "source-rate-capture-unavailable" },
|
|
944
|
+
downloadForecast: forecastCapture },
|
|
847
945
|
downloadForecast,
|
|
848
946
|
});
|
|
849
947
|
}
|
|
@@ -852,36 +950,38 @@ export class ViewerRequests {
|
|
|
852
950
|
const observedMbps = output === session
|
|
853
951
|
? outputMbps
|
|
854
952
|
: await this.#host.quality.observedStreamMbps(output);
|
|
855
|
-
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
953
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
856
954
|
parts.qualityMs += performance.now() - qualityAt;
|
|
857
955
|
const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
|
|
956
|
+
const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
|
|
957
|
+
const encoderCaptureValid = encoderCapture.validity === "valid";
|
|
858
958
|
const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
|
|
859
959
|
const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
|
|
860
960
|
tracks.push({
|
|
861
961
|
id: output.outputKey,
|
|
862
962
|
clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
|
|
863
|
-
clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
864
|
-
viewerReading?.bufferedRanges?.media,
|
|
963
|
+
clientRanges: bufferCaptureValid ? viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
964
|
+
viewerReading?.bufferedRanges?.media : null,
|
|
865
965
|
// Browser-observed append and fragment-parse samples are retained for
|
|
866
966
|
// the full model's service schedule; they are not yet used as numeric
|
|
867
967
|
// predecessors in the partial continuity calculation.
|
|
868
|
-
appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
968
|
+
appendService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
869
969
|
? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
|
|
870
970
|
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
871
971
|
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
872
972
|
: null,
|
|
873
|
-
fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
973
|
+
fragmentService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
874
974
|
? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
|
|
875
975
|
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
876
976
|
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
877
977
|
: null,
|
|
878
978
|
sourceIds,
|
|
879
|
-
processedSeconds: trackProgress?.processedSeconds,
|
|
880
|
-
startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
|
|
979
|
+
processedSeconds: encoderCaptureValid ? trackProgress?.processedSeconds : null,
|
|
980
|
+
startupRemainingSeconds: !encoderCaptureValid ? null : processingStarted ? 0 : Math.max(0,
|
|
881
981
|
(this.#host.processingStartupSecondsOf?.(output) ?? 0) - Math.max(0, (now - startedAt) / 1000)),
|
|
882
|
-
processingRanges: liveRuns.map(({ progress }) => ({
|
|
982
|
+
processingRanges: encoderCaptureValid ? liveRuns.map(({ progress }) => ({
|
|
883
983
|
start: progress.startPositionSeconds, end: progress.processedSeconds
|
|
884
|
-
})),
|
|
984
|
+
})) : [],
|
|
885
985
|
// What a run's start costs this output, and the stretches its live
|
|
886
986
|
// runs were given: the forecast starts a run wherever production meets
|
|
887
987
|
// bytes that were not held when the run before was admitted.
|
|
@@ -894,7 +994,8 @@ export class ViewerRequests {
|
|
|
894
994
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
895
995
|
durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
|
|
896
996
|
segmentCount: output.timeline?.segmentCount ?? segments.length,
|
|
897
|
-
readings: trackRates,
|
|
997
|
+
readings: encoderCaptureValid ? trackRates : [],
|
|
998
|
+
capture: encoderCapture,
|
|
898
999
|
segments,
|
|
899
1000
|
geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
|
|
900
1001
|
readySegmentIndices: [...inventory.keys()],
|
|
@@ -902,10 +1003,10 @@ export class ViewerRequests {
|
|
|
902
1003
|
});
|
|
903
1004
|
}
|
|
904
1005
|
|
|
905
|
-
if (!stillOwnsProgress()) return null;
|
|
1006
|
+
if (!stillOwnsProgress() || !actionIsCurrent()) return null;
|
|
906
1007
|
|
|
907
|
-
const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
|
|
908
|
-
const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
|
|
1008
|
+
const bufferedAheadSeconds = bufferCaptureValid ? Number(viewerReading?.bufferedAheadSec) : null;
|
|
1009
|
+
const positionSeconds = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
|
|
909
1010
|
? viewerReading.positionSeconds
|
|
910
1011
|
: this.#host.viewerSecondsOn(session, consumerId, now);
|
|
911
1012
|
// Both separately delivered tracks can run short independently. Use the
|
|
@@ -932,7 +1033,7 @@ export class ViewerRequests {
|
|
|
932
1033
|
positionSeconds,
|
|
933
1034
|
durationSeconds: session.file.durationSeconds,
|
|
934
1035
|
bufferedAheadSeconds,
|
|
935
|
-
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
1036
|
+
bufferLimitSeconds: bufferCaptureValid ? viewerReading?.bufferLimitSeconds : null,
|
|
936
1037
|
reserveSeconds,
|
|
937
1038
|
lookaheadSeconds: this.#host.lookaheadSeconds,
|
|
938
1039
|
// Disk room remaining after stock reserved for other active outputs.
|
|
@@ -940,7 +1041,7 @@ export class ViewerRequests {
|
|
|
940
1041
|
requiredAudio: requiresSeparateAudio,
|
|
941
1042
|
sources: [...sourceMeasurements.values()],
|
|
942
1043
|
tracks,
|
|
943
|
-
linkReadings: measurement.link.snapshot()
|
|
1044
|
+
linkReadings: viewerReading?.linkCapture?.validity === "valid" ? measurement.link.snapshot() : null
|
|
944
1045
|
};
|
|
945
1046
|
// A subtitle track chosen inside THIS file is the last input the forecast
|
|
946
1047
|
// waits for. A subtitle FILE beside it is named by its own index and is
|
|
@@ -960,6 +1061,18 @@ export class ViewerRequests {
|
|
|
960
1061
|
const forecast = withSubtitleReadiness(predicted, subtitle,
|
|
961
1062
|
subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
|
|
962
1063
|
forecast.snapshotAtProxyMs = startedAt;
|
|
1064
|
+
forecast.snapshotClockEpoch = performance.timeOrigin;
|
|
1065
|
+
forecast.inputCaptures = {
|
|
1066
|
+
source: [...sourceMeasurements].map(([id, source]) => ({ id, capture: source.capture })),
|
|
1067
|
+
link: { ...(viewerReading?.linkCapture ?? unknownCapture("browser", actionId, generation,
|
|
1068
|
+
"link-capture-unavailable")), rateReadings: measurement.link.captureSnapshot() },
|
|
1069
|
+
encoder: tracks.map(track => ({ id: track.id, capture: track.capture })),
|
|
1070
|
+
buffer: bufferCaptureValid || rawBufferCapture.validity === "unknown" ? rawBufferCapture : {
|
|
1071
|
+
...rawBufferCapture, validity: "invalid",
|
|
1072
|
+
unknownReason: rawBufferCapture.generation !== generation ? "seek-generation-replaced" :
|
|
1073
|
+
rawBufferCapture.actionId !== session.id ? "viewer-session-replaced" : "viewer-output-cancelled"
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
963
1076
|
forecast.operations = outputs.flatMap((output, index) => {
|
|
964
1077
|
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
|
|
965
1078
|
processedSeconds: tracks[index].processedSeconds,
|
|
@@ -976,8 +1089,6 @@ export class ViewerRequests {
|
|
|
976
1089
|
outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
|
|
977
1090
|
];
|
|
978
1091
|
});
|
|
979
|
-
const generation = consumerId && viewer?.assignments
|
|
980
|
-
? this.#host.generationOfRequest(consumerId) : null;
|
|
981
1092
|
const earlyWaitId = Number.isSafeInteger(generation)
|
|
982
1093
|
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
983
1094
|
if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
|
|
@@ -1136,7 +1247,20 @@ export class ViewerRequests {
|
|
|
1136
1247
|
#trackRateReadings(output, now) {
|
|
1137
1248
|
const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
|
|
1138
1249
|
const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
|
|
1139
|
-
if (summary) return summary
|
|
1250
|
+
if (summary) return { summary, captures: readings.map(reading => ({ owner: reading.captureOwner,
|
|
1251
|
+
clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
|
|
1252
|
+
startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
|
|
1253
|
+
revision: reading.at,
|
|
1254
|
+
validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
1255
|
+
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
1256
|
+
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
1257
|
+
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
1258
|
+
? "valid" : "unknown",
|
|
1259
|
+
unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
1260
|
+
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
1261
|
+
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
1262
|
+
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
1263
|
+
? null : "encoder-sample-capture-unavailable" })) };
|
|
1140
1264
|
// ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
|
|
1141
1265
|
// encoder, and it is not kept among the readings. Added on every poll
|
|
1142
1266
|
// between runs, it put the machine's share of that moment — 1 % at a
|
|
@@ -1146,7 +1270,9 @@ export class ViewerRequests {
|
|
|
1146
1270
|
if (Number.isFinite(projected) && projected > 0) {
|
|
1147
1271
|
const seed = new this.#host.playbackReadiness.RateTrend();
|
|
1148
1272
|
seed.add(now, projected);
|
|
1149
|
-
return seed.snapshot()
|
|
1273
|
+
return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
|
|
1274
|
+
clockEpoch: performance.timeOrigin, startedAt: now, completedAt: now, revision: now,
|
|
1275
|
+
validity: "valid", unknownReason: null }] };
|
|
1150
1276
|
}
|
|
1151
1277
|
// Progress over wall time includes source waits, which the forecast prices
|
|
1152
1278
|
// separately. Keep the rate unknown until a measured or calibrated
|
|
@@ -42,6 +42,9 @@ import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../../storage/memory
|
|
|
42
42
|
import { profileThisThread } from "../../storage/cpu-profile.js";
|
|
43
43
|
import { forwardLogsTo, logger } from "../../../utils/logger.js";
|
|
44
44
|
|
|
45
|
+
const WORKER_CLOCK_EPOCH = performance.timeOrigin;
|
|
46
|
+
let sourceSnapshotRevision = 0;
|
|
47
|
+
|
|
45
48
|
/**
|
|
46
49
|
* Everything this thread logs goes to the main thread, which owns the file.
|
|
47
50
|
*
|
|
@@ -492,6 +495,8 @@ async function runCommand(command, params, id) {
|
|
|
492
495
|
|
|
493
496
|
case Command.FILE_STATS: {
|
|
494
497
|
const statsStartedAt = performance.now();
|
|
498
|
+
const sourceCaptureStartedAt = statsStartedAt;
|
|
499
|
+
const sourceRevision = ++sourceSnapshotRevision;
|
|
495
500
|
emitWaitTrace(params.sourceKey, "W10", "begin", { startedAt: statsStartedAt,
|
|
496
501
|
fileIndex: params.fileIndex, forecast: params.forecast === true });
|
|
497
502
|
const finishStatsTrace = (outcome, fields = {}) => {
|
|
@@ -504,7 +509,14 @@ async function runCommand(command, params, id) {
|
|
|
504
509
|
if (!torrent) {
|
|
505
510
|
const whole = wholeSources.fileOf(params.sourceKey, params.fileIndex);
|
|
506
511
|
finishStatsTrace(whole ? "whole-file" : "pending");
|
|
507
|
-
return
|
|
512
|
+
return {
|
|
513
|
+
...(whole ? wholeFileStats(whole.length) : { pending: true }),
|
|
514
|
+
sourceCapture: {
|
|
515
|
+
owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
|
|
516
|
+
startedAt: sourceCaptureStartedAt, completedAt: performance.now(), revision: sourceRevision,
|
|
517
|
+
validity: whole ? "valid" : "unknown", unknownReason: whole ? null : "source-not-ready"
|
|
518
|
+
}
|
|
519
|
+
};
|
|
508
520
|
}
|
|
509
521
|
// What this file's own interruptions demand, measured by the reader in
|
|
510
522
|
// this thread. It travels with the stats because the caller asking for
|
|
@@ -515,7 +527,8 @@ async function runCommand(command, params, id) {
|
|
|
515
527
|
const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
|
|
516
528
|
const forecastAt = performance.now();
|
|
517
529
|
const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
|
|
518
|
-
const
|
|
530
|
+
const forecastCompletedAt = performance.now();
|
|
531
|
+
const forecastMs = params.forecast === true ? forecastCompletedAt - forecastAt : 0;
|
|
519
532
|
const stats = pool.getFileStats(torrent, params.fileIndex);
|
|
520
533
|
const ranges = [];
|
|
521
534
|
if (forecast) for (const [piece, availableAt] of forecast.arrivals) {
|
|
@@ -526,8 +539,19 @@ async function runCommand(command, params, id) {
|
|
|
526
539
|
finishStatsTrace("ok", { forecast: params.forecast === true });
|
|
527
540
|
return {
|
|
528
541
|
...stats,
|
|
542
|
+
sourceCapture: {
|
|
543
|
+
owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
|
|
544
|
+
startedAt: sourceCaptureStartedAt, completedAt: performance.now(), revision: sourceRevision,
|
|
545
|
+
validity: stats ? "valid" : "unknown", unknownReason: stats ? null : "source-stats-unavailable"
|
|
546
|
+
},
|
|
529
547
|
...(params.forecast === true ? { downloadForecast: {
|
|
530
|
-
measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges
|
|
548
|
+
measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges,
|
|
549
|
+
capture: {
|
|
550
|
+
owner: "torrent-worker", clock: "performance", clockEpoch: WORKER_CLOCK_EPOCH,
|
|
551
|
+
startedAt: forecastAt, completedAt: forecastCompletedAt, revision: sourceRevision,
|
|
552
|
+
validity: forecast ? "valid" : "unknown",
|
|
553
|
+
unknownReason: forecast ? null : "source-arrival-forecast-unavailable"
|
|
554
|
+
}
|
|
531
555
|
} } : {}),
|
|
532
556
|
supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4),
|
|
533
557
|
// How long this answer waited for the arrival forecast, which the
|
|
@@ -53,6 +53,34 @@ function fragmentEventAges(value) {
|
|
|
53
53
|
Number.isFinite(value?.[name]) && value[name] >= 0 ? value[name] : null]));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function browserCaptureOf(report, sessionId) {
|
|
57
|
+
const clockEpoch = report.captureClockEpoch;
|
|
58
|
+
const startedAt = report.captureStartedAtBrowser;
|
|
59
|
+
const completedAt = report.captureCompletedAtBrowser;
|
|
60
|
+
const valid = typeof report.captureEpoch === "string" && report.captureEpoch.length > 0 &&
|
|
61
|
+
Number.isFinite(report.captureEpochStartedAtBrowser) && Number.isFinite(report.captureRevision) &&
|
|
62
|
+
Number.isSafeInteger(report.captureRevision) && report.captureRevision > 0 &&
|
|
63
|
+
Number.isFinite(clockEpoch) && Number.isFinite(startedAt) && Number.isFinite(completedAt) &&
|
|
64
|
+
report.captureEpochStartedAtBrowser >= clockEpoch && startedAt >= report.captureEpochStartedAtBrowser &&
|
|
65
|
+
completedAt >= startedAt &&
|
|
66
|
+
Number.isSafeInteger(report.generation) && report.generation >= 0;
|
|
67
|
+
return {
|
|
68
|
+
owner: "browser",
|
|
69
|
+
clock: "performance",
|
|
70
|
+
clockEpoch: Number.isFinite(clockEpoch) ? clockEpoch : null,
|
|
71
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : null,
|
|
72
|
+
completedAt: Number.isFinite(completedAt) ? completedAt : null,
|
|
73
|
+
actionId: typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null,
|
|
74
|
+
generation: Number.isSafeInteger(report.generation) ? report.generation : null,
|
|
75
|
+
revision: Number.isSafeInteger(report.captureRevision) ? report.captureRevision : null,
|
|
76
|
+
epoch: typeof report.captureEpoch === "string" ? report.captureEpoch : null,
|
|
77
|
+
epochStartedAt: Number.isFinite(report.captureEpochStartedAtBrowser)
|
|
78
|
+
? report.captureEpochStartedAtBrowser : null,
|
|
79
|
+
validity: valid ? "valid" : "unknown",
|
|
80
|
+
unknownReason: valid ? null : "missing-or-invalid-browser-capture"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
function appendServiceSnapshot(value) {
|
|
57
85
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
58
86
|
const snapshot = {};
|
|
@@ -522,6 +550,18 @@ export class Viewer {
|
|
|
522
550
|
appendEpoch = null,
|
|
523
551
|
appendEpochStartedAt = null,
|
|
524
552
|
appendSnapshotAt = null,
|
|
553
|
+
captureClockEpoch = null,
|
|
554
|
+
captureEpoch = null,
|
|
555
|
+
captureEpochStartedAtBrowser = null,
|
|
556
|
+
captureRevision = null,
|
|
557
|
+
captureStartedAtBrowser = null,
|
|
558
|
+
captureCompletedAtBrowser = null,
|
|
559
|
+
linkSampleStartedAtBrowser = null,
|
|
560
|
+
linkSampleCompletedAtBrowser = null,
|
|
561
|
+
linkSampleActionId = null,
|
|
562
|
+
linkSampleGeneration = null,
|
|
563
|
+
linkSampleClockEpoch = null,
|
|
564
|
+
linkSampleRevision = null,
|
|
525
565
|
appendService: appendServiceReading = null,
|
|
526
566
|
fragmentService: fragmentServiceReading = null,
|
|
527
567
|
bufferedAheadSec,
|
|
@@ -564,6 +604,18 @@ export class Viewer {
|
|
|
564
604
|
const sampleMbps = Number(linkSampleMbps);
|
|
565
605
|
const sampleAt = Number(linkSampleAt);
|
|
566
606
|
const previous = this.netReport;
|
|
607
|
+
const browserCapture = browserCaptureOf({ captureClockEpoch, captureEpoch, captureEpochStartedAtBrowser,
|
|
608
|
+
captureRevision, captureStartedAtBrowser, captureCompletedAtBrowser, generation }, appendSessionId);
|
|
609
|
+
const previousCapture = previous?.bufferCapture ?? null;
|
|
610
|
+
const captureOrderKnown = browserCapture.clockEpoch !== null && browserCapture.epoch !== null &&
|
|
611
|
+
browserCapture.epochStartedAt !== null && browserCapture.revision !== null && browserCapture.revision > 0;
|
|
612
|
+
const sameCaptureClock = captureOrderKnown && browserCapture.clockEpoch === previousCapture?.clockEpoch;
|
|
613
|
+
const sameCaptureEpoch = sameCaptureClock && browserCapture.epoch === previousCapture?.epoch;
|
|
614
|
+
const newerCapture = captureOrderKnown && (!previousCapture || !previousCapture.clockEpoch ||
|
|
615
|
+
!sameCaptureClock && browserCapture.epochStartedAt > (previousCapture.epochStartedAt ?? 0) ||
|
|
616
|
+
sameCaptureClock && (sameCaptureEpoch && browserCapture.revision > previousCapture.revision ||
|
|
617
|
+
!sameCaptureEpoch && browserCapture.epochStartedAt > (previousCapture.epochStartedAt ?? 0)));
|
|
618
|
+
const staleCapture = captureOrderKnown && previousCapture && !newerCapture;
|
|
567
619
|
const incomingAppendService = typeof appendEpoch === "string" && appendEpoch.length > 0
|
|
568
620
|
? appendServiceSnapshot(appendServiceReading) : null;
|
|
569
621
|
const incomingFragmentService = typeof appendEpoch === "string" && appendEpoch.length > 0
|
|
@@ -578,37 +630,85 @@ export class Viewer {
|
|
|
578
630
|
(!previous?.appendService || appendServiceIsMonotonic(incomingAppendService, previous.appendService)) &&
|
|
579
631
|
(!previous?.fragmentService || fragmentServiceIsMonotonic(incomingFragmentService, previous.fragmentService))
|
|
580
632
|
: appendEpochStartedAt > (previous?.appendEpochStartedAt ?? 0));
|
|
633
|
+
const preserveBrowserService = sameAppendEpoch && incomingAppendService && incomingFragmentService;
|
|
581
634
|
const hasPlaybackSample = Number.isFinite(sampleMbps) && sampleMbps > 0 &&
|
|
582
|
-
Number.isFinite(sampleAt) && sampleAt > 0
|
|
583
|
-
|
|
635
|
+
Number.isFinite(sampleAt) && sampleAt > 0 && Number.isFinite(captureClockEpoch) &&
|
|
636
|
+
Number.isFinite(linkSampleClockEpoch) && linkSampleClockEpoch === captureClockEpoch &&
|
|
637
|
+
Number.isSafeInteger(linkSampleRevision) && linkSampleRevision > 0 &&
|
|
638
|
+
Number.isFinite(linkSampleStartedAtBrowser) && Number.isFinite(linkSampleCompletedAtBrowser) &&
|
|
639
|
+
linkSampleStartedAtBrowser >= captureClockEpoch && linkSampleCompletedAtBrowser === sampleAt &&
|
|
640
|
+
linkSampleStartedAtBrowser <= linkSampleCompletedAtBrowser &&
|
|
641
|
+
Number.isFinite(captureCompletedAtBrowser) && sampleAt <= captureCompletedAtBrowser &&
|
|
642
|
+
Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0;
|
|
643
|
+
const oldLinkEpoch = this.netReport?.linkCapture?.epoch !== String(linkSampleClockEpoch) &&
|
|
644
|
+
Number.isFinite(this.netReport?.linkCapture?.epochStartedAt) &&
|
|
645
|
+
Number.isFinite(linkSampleClockEpoch) &&
|
|
646
|
+
linkSampleClockEpoch <= this.netReport.linkCapture.epochStartedAt;
|
|
647
|
+
const newerPlaybackSample = hasPlaybackSample && !oldLinkEpoch &&
|
|
648
|
+
(linkSampleClockEpoch !== this.netReport?.linkCapture?.clockEpoch ||
|
|
649
|
+
linkSampleRevision > (this.netReport?.linkCapture?.revision ?? 0));
|
|
650
|
+
const linkClockEpochChanged = Number.isFinite(captureClockEpoch) &&
|
|
651
|
+
Number.isFinite(this.netReport?.linkCapture?.clockEpoch) &&
|
|
652
|
+
captureClockEpoch !== this.netReport.linkCapture.clockEpoch;
|
|
653
|
+
const newerInvalidPlaybackSample = !stale && newerCapture && !hasPlaybackSample &&
|
|
654
|
+
(Number.isSafeInteger(linkSampleRevision) || linkSampleAt !== null && linkSampleAt !== undefined ||
|
|
655
|
+
linkClockEpochChanged);
|
|
656
|
+
const preserveCapture = stale || staleCapture;
|
|
657
|
+
const linkCapture = newerPlaybackSample ? {
|
|
658
|
+
owner: "browser", clock: "performance", clockEpoch: linkSampleClockEpoch,
|
|
659
|
+
startedAt: linkSampleStartedAtBrowser, completedAt: linkSampleCompletedAtBrowser,
|
|
660
|
+
actionId: typeof linkSampleActionId === "string" ? linkSampleActionId : null,
|
|
661
|
+
generation: Number.isSafeInteger(linkSampleGeneration) ? linkSampleGeneration : null,
|
|
662
|
+
epoch: String(linkSampleClockEpoch), epochStartedAt: linkSampleClockEpoch,
|
|
663
|
+
revision: linkSampleRevision, validity: "valid", unknownReason: null
|
|
664
|
+
} : newerInvalidPlaybackSample ? {
|
|
665
|
+
owner: "browser", clock: "performance", clockEpoch: Number.isFinite(captureClockEpoch) ? captureClockEpoch : null,
|
|
666
|
+
startedAt: null, completedAt: null, actionId: appendSessionId,
|
|
667
|
+
generation: Number.isSafeInteger(generation) ? generation : null,
|
|
668
|
+
epoch: Number.isFinite(captureClockEpoch) ? String(captureClockEpoch) : null,
|
|
669
|
+
epochStartedAt: captureClockEpoch,
|
|
670
|
+
revision: linkSampleRevision, validity: "invalid", unknownReason: "invalid-link-sample-capture"
|
|
671
|
+
} : this.netReport?.linkCapture ?? {
|
|
672
|
+
owner: "browser", clock: "performance", clockEpoch: null, startedAt: null, completedAt: null,
|
|
673
|
+
actionId: appendSessionId, generation: Number.isSafeInteger(generation) ? generation : null,
|
|
674
|
+
epoch: null, epochStartedAt: null, revision: null, validity: "unknown",
|
|
675
|
+
unknownReason: "missing-link-sample-capture"
|
|
676
|
+
};
|
|
677
|
+
const useCapture = !stale && !staleCapture && newerCapture && browserCapture.validity === "valid";
|
|
584
678
|
this.netReport = {
|
|
585
679
|
linkMbps: lastLinkMbps,
|
|
586
680
|
linkMeasuredAt,
|
|
587
|
-
linkSampleAt: newerPlaybackSample ? sampleAt : this.netReport?.linkSampleAt ?? null,
|
|
588
|
-
linkSampleMbps: newerPlaybackSample ? sampleMbps : this.netReport?.linkSampleMbps ?? null,
|
|
589
|
-
linkSampleMeasuredAt: newerPlaybackSample
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
:
|
|
681
|
+
linkSampleAt: newerPlaybackSample ? sampleAt : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleAt ?? null,
|
|
682
|
+
linkSampleMbps: newerPlaybackSample ? sampleMbps : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleMbps ?? null,
|
|
683
|
+
linkSampleMeasuredAt: newerPlaybackSample ? now : newerInvalidPlaybackSample ? null : this.netReport?.linkSampleMeasuredAt ?? null,
|
|
684
|
+
linkCapture,
|
|
685
|
+
bufferCapture: preserveCapture ? previousCapture ?? browserCapture : useCapture ? browserCapture : {
|
|
686
|
+
...browserCapture,
|
|
687
|
+
validity: "unknown",
|
|
688
|
+
unknownReason: browserCapture.unknownReason ?? "missing-or-invalid-browser-capture"
|
|
689
|
+
},
|
|
594
690
|
appendSessionId: useBrowserService ? appendSessionId : previous?.appendSessionId ?? null,
|
|
595
|
-
appendEpoch: useBrowserService ? appendEpoch : previous?.appendEpoch ?? null
|
|
691
|
+
appendEpoch: useBrowserService ? appendEpoch : preserveBrowserService ? previous?.appendEpoch ?? null
|
|
692
|
+
: useCapture ? null : previous?.appendEpoch ?? null,
|
|
596
693
|
appendEpochStartedAt: useBrowserService ? appendEpochStartedAt : previous?.appendEpochStartedAt ?? null,
|
|
597
694
|
appendSnapshotAt: useBrowserService ? appendSnapshotAt : previous?.appendSnapshotAt ?? null,
|
|
598
|
-
appendService: useBrowserService ? incomingAppendService : previous?.appendService ?? null
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
695
|
+
appendService: useBrowserService ? incomingAppendService : preserveBrowserService ? previous?.appendService ?? null
|
|
696
|
+
: useCapture ? null : previous?.appendService ?? null,
|
|
697
|
+
fragmentService: useBrowserService ? incomingFragmentService : preserveBrowserService ? previous?.fragmentService ?? null
|
|
698
|
+
: useCapture ? null : previous?.fragmentService ?? null,
|
|
699
|
+
appendServiceReceivedAt: useBrowserService ? now : preserveBrowserService ? previous?.appendServiceReceivedAt ?? null
|
|
700
|
+
: useCapture ? null : previous?.appendServiceReceivedAt ?? null,
|
|
701
|
+
bufferedAheadSec: preserveCapture ? previous?.bufferedAheadSec ?? 0 : useCapture ? held : 0,
|
|
602
702
|
timestampOffsets: Object.fromEntries(["video", "audio"].filter((key) =>
|
|
603
703
|
Number.isFinite(timestampOffsets?.[key])).map((key) => [key, timestampOffsets[key]])),
|
|
604
|
-
bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
|
|
704
|
+
bufferedRanges: preserveCapture ? previous?.bufferedRanges ?? null : useCapture && bufferedRanges && typeof bufferedRanges === "object" ?
|
|
605
705
|
Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
|
|
606
706
|
.map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
|
|
607
707
|
Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.start >= 0 && range.end > range.start)
|
|
608
708
|
.map(({ start, end }) => ({ start, end }))])) : null,
|
|
609
|
-
bufferLimitSeconds: Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
|
|
709
|
+
bufferLimitSeconds: preserveCapture ? previous?.bufferLimitSeconds ?? null : useCapture && Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
|
|
610
710
|
? bufferLimitSeconds
|
|
611
|
-
: this.netReport?.bufferLimitSeconds ?? null,
|
|
711
|
+
: useCapture ? null : this.netReport?.bufferLimitSeconds ?? null,
|
|
612
712
|
positionSeconds:
|
|
613
713
|
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
614
714
|
at: now
|
|
@@ -46,11 +46,15 @@ export class ViewerPlaybackState {
|
|
|
46
46
|
return removed;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
addLinkReading(state, at, mbps) {
|
|
50
|
-
state.link.add(at, mbps * 1_000_000);
|
|
49
|
+
addLinkReading(state, at, mbps, capture = null) {
|
|
50
|
+
state.link.add(at, mbps * 1_000_000, capture);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
forgetLinkReadings(state, playbackReadiness, lookaheadSeconds) {
|
|
54
|
+
if (state) state.link = new playbackReadiness.RateTrend(lookaheadSeconds);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sourceRateReadings(state, id, stats, now, playbackReadiness, lookaheadSeconds, capture = null) {
|
|
54
58
|
let source = state.sources.get(id);
|
|
55
59
|
if (!source) {
|
|
56
60
|
const { RateTrend, SessionRate } = playbackReadiness;
|
|
@@ -58,15 +62,16 @@ export class ViewerPlaybackState {
|
|
|
58
62
|
deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
|
|
59
63
|
state.sources.set(id, source);
|
|
60
64
|
}
|
|
65
|
+
const readingCapture = capture ?? stats?.sourceCapture ?? null;
|
|
61
66
|
if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
|
|
62
|
-
source.delivered.add(now, stats.downloadSpeed);
|
|
63
|
-
source.deliveredAll.add(now, stats.downloadSpeed);
|
|
67
|
+
source.delivered.add(now, stats.downloadSpeed, readingCapture);
|
|
68
|
+
source.deliveredAll.add(now, stats.downloadSpeed, readingCapture);
|
|
64
69
|
}
|
|
65
70
|
const limit = stats?.selectionLimit;
|
|
66
71
|
if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
|
|
67
72
|
const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
|
|
68
|
-
source.free.add(now, free);
|
|
69
|
-
source.freeAll.add(now, free);
|
|
73
|
+
source.free.add(now, free, readingCapture);
|
|
74
|
+
source.freeAll.add(now, free, readingCapture);
|
|
70
75
|
}
|
|
71
76
|
if (limit) source.limit = limit;
|
|
72
77
|
return sourceService(source.free.snapshot(), source.delivered.snapshot(),
|
|
@@ -85,6 +90,19 @@ export class ViewerPlaybackState {
|
|
|
85
90
|
return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
sourceCaptureSnapshot(state, id) {
|
|
94
|
+
const source = state.sources.get(id);
|
|
95
|
+
if (!source) return null;
|
|
96
|
+
return {
|
|
97
|
+
recent: { delivered: source.delivered.captureSnapshot(), free: source.free.captureSnapshot() },
|
|
98
|
+
tail: { delivered: source.deliveredAll.captureSnapshot(), free: source.freeAll.captureSnapshot() }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
forgetSourceReadings(state, id) {
|
|
103
|
+
state?.sources.delete(id);
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
getWait(viewer, waitId) {
|
|
89
107
|
return this.#stateFor(viewer)?.waits.get(waitId) ?? null;
|
|
90
108
|
}
|
|
@@ -149,14 +149,16 @@ export class SessionRate {
|
|
|
149
149
|
#inverseSum = 0;
|
|
150
150
|
#zero = false;
|
|
151
151
|
#weighted = 0;
|
|
152
|
+
#capture = null;
|
|
152
153
|
|
|
153
|
-
add(at, value) {
|
|
154
|
+
add(at, value, capture = null) {
|
|
154
155
|
if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 || at <= (this.#last?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
155
156
|
return false;
|
|
156
157
|
}
|
|
157
158
|
if (this.#last) this.#weighted += this.#last.value * (at - this.#last.at);
|
|
158
159
|
this.#first ??= { at, value };
|
|
159
160
|
this.#last = { at, value };
|
|
161
|
+
this.#capture = mergeCaptureSummary(this.#capture, capture);
|
|
160
162
|
this.#count += 1;
|
|
161
163
|
this.#sum += value;
|
|
162
164
|
if (value > 0) this.#inverseSum += 1 / value;
|
|
@@ -177,6 +179,10 @@ export class SessionRate {
|
|
|
177
179
|
spanSeconds: spanMs / 1000
|
|
178
180
|
};
|
|
179
181
|
}
|
|
182
|
+
|
|
183
|
+
captureSnapshot() {
|
|
184
|
+
return this.#capture ? { ...this.#capture } : null;
|
|
185
|
+
}
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
/**
|
|
@@ -192,12 +198,12 @@ export class RateTrend {
|
|
|
192
198
|
this.#horizonMs = Math.max(0, horizonSeconds) * 1000;
|
|
193
199
|
}
|
|
194
200
|
|
|
195
|
-
add(at, value) {
|
|
201
|
+
add(at, value, capture = null) {
|
|
196
202
|
if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 ||
|
|
197
203
|
at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
198
204
|
return false;
|
|
199
205
|
}
|
|
200
|
-
this.#readings.push({ at, value });
|
|
206
|
+
this.#readings.push({ at, value, capture: capture ? { ...capture } : null });
|
|
201
207
|
const cutoff = at - this.#horizonMs;
|
|
202
208
|
while (this.#readings.length > 1 && this.#readings[1].at <= cutoff) {
|
|
203
209
|
this.#readings.shift();
|
|
@@ -211,6 +217,22 @@ export class RateTrend {
|
|
|
211
217
|
}
|
|
212
218
|
return summarizeRate(this.#readings);
|
|
213
219
|
}
|
|
220
|
+
|
|
221
|
+
captureSnapshot() {
|
|
222
|
+
return this.#readings.map(reading => reading.capture ? { ...reading.capture } : null);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mergeCaptureSummary(previous, capture) {
|
|
227
|
+
if (!capture || capture.validity !== "valid" || !Number.isFinite(capture.clockEpoch) ||
|
|
228
|
+
!Number.isFinite(capture.startedAt) || !Number.isFinite(capture.completedAt) ||
|
|
229
|
+
capture.completedAt < capture.startedAt) return { validity: "unknown", unknownReason: "missing-rate-capture" };
|
|
230
|
+
if (!previous) return { ...capture };
|
|
231
|
+
if (previous.validity !== "valid" || previous.clockEpoch !== capture.clockEpoch) {
|
|
232
|
+
return { validity: "invalid", unknownReason: "rate-capture-epoch-mismatch" };
|
|
233
|
+
}
|
|
234
|
+
return { ...previous, startedAt: Math.min(previous.startedAt, capture.startedAt),
|
|
235
|
+
completedAt: Math.max(previous.completedAt, capture.completedAt), revision: capture.revision };
|
|
214
236
|
}
|
|
215
237
|
|
|
216
238
|
/**
|