@torrent-tv/proxy 2.107.52 → 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/files/get.js +6 -4
- package/routes/api/sources/stats/get.js +8 -6
- 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/encode/quality/HostLoad.js +1 -1
- package/services/server/ForecastDiagnostics.js +23 -0
- package/services/server/OutputLifecycle.js +7 -1
- package/services/server/ViewerRequests.js +235 -317
- package/services/server/wire-outputs.js +17 -0
- package/services/torrent/worker/worker.js +27 -3
- package/services/viewer/OutputForecastGeometry.js +87 -0
- package/services/viewer/Viewer.js +117 -17
- package/services/viewer/ViewerPlaybackState.js +233 -0
- package/services/viewer/playback-readiness.js +25 -3
|
@@ -107,6 +107,9 @@ import { describeProcessor, processorBetween, sampleProcessor } from "../storage
|
|
|
107
107
|
import { ProcessorBudget } from "../encode/ProcessorBudget.js";
|
|
108
108
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
109
109
|
import { secondsUntilClosed } from "../encode/piece-close-time.js";
|
|
110
|
+
import { ViewerPlaybackState } from "../viewer/ViewerPlaybackState.js";
|
|
111
|
+
import { OutputForecastGeometry } from "../viewer/OutputForecastGeometry.js";
|
|
112
|
+
import { ForecastDiagnostics } from "./ForecastDiagnostics.js";
|
|
110
113
|
|
|
111
114
|
// Where a variant and an audio rendition live under a session — `v/<height>/…`
|
|
112
115
|
// and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
|
|
@@ -249,6 +252,14 @@ export function wireOutputs({
|
|
|
249
252
|
// output key owns its record, and the graph releases it at the last session.
|
|
250
253
|
const waits = new WaitLedger();
|
|
251
254
|
const parts = {};
|
|
255
|
+
parts.viewerPlaybackState = new ViewerPlaybackState();
|
|
256
|
+
parts.outputForecastGeometry = new OutputForecastGeometry({
|
|
257
|
+
lookaheadSeconds: LOOKAHEAD_PAUSE_SECONDS,
|
|
258
|
+
finishedMediaRangesOf: (output, index) => parts.encodeRuns.finishedMediaRangesOf(output, index),
|
|
259
|
+
sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
|
|
260
|
+
endsWithinPlayerSeekTolerance
|
|
261
|
+
});
|
|
262
|
+
parts.forecastDiagnostics = new ForecastDiagnostics({ logger });
|
|
252
263
|
const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
|
|
253
264
|
sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
|
|
254
265
|
}) ?? []).find((track) => track.fileIndex === output.spec.audio?.fileIndex &&
|
|
@@ -573,6 +584,9 @@ export function wireOutputs({
|
|
|
573
584
|
planEncodersSoon: () => parts.encodeRuns.planEncodersSoon(),
|
|
574
585
|
invalidateWaits: (output) => parts.serving.invalidateWaits(output),
|
|
575
586
|
clearWaits: () => waits.clear(),
|
|
587
|
+
forgetForecastOutput: (output) => parts.viewerRequests.outputDisposed(output),
|
|
588
|
+
viewerDeparted: (viewer) => parts.viewerRequests.viewerDeparted(viewer),
|
|
589
|
+
outputViewerDeparted: (viewer, output) => parts.viewerRequests.outputViewerDeparted(viewer, output),
|
|
576
590
|
segmentFormatOfKey,
|
|
577
591
|
// Where the viewers of one output stand, in its segments: what the store
|
|
578
592
|
// the output retention policy reads to give disk back in the right order.
|
|
@@ -632,6 +646,9 @@ export function wireOutputs({
|
|
|
632
646
|
// What a viewer asks for: an output of a file, a position, a report of progress.
|
|
633
647
|
parts.viewerRequests = new ViewerRequests({
|
|
634
648
|
observeWait,
|
|
649
|
+
viewerPlaybackState: parts.viewerPlaybackState,
|
|
650
|
+
outputForecastGeometry: parts.outputForecastGeometry,
|
|
651
|
+
noteForecast: (...args) => parts.forecastDiagnostics.note(...args),
|
|
635
652
|
// Whether this machine has a place for one more viewer on this output —
|
|
636
653
|
// asked in the same synchronous stretch as they are put on it.
|
|
637
654
|
admitsWatching: (output) => parts.admission.admitsWatching(output?.outputKey ?? ""),
|
|
@@ -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
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached piece geometry for playback forecasts. It is keyed by a live output
|
|
3
|
+
* object and explicitly forgotten when that output is disposed.
|
|
4
|
+
*/
|
|
5
|
+
export class OutputForecastGeometry {
|
|
6
|
+
#byOutput = new Map();
|
|
7
|
+
#host;
|
|
8
|
+
|
|
9
|
+
constructor(host) {
|
|
10
|
+
this.#host = host;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get(output) {
|
|
14
|
+
return this.#byOutput.get(output) ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
forget(output) {
|
|
18
|
+
return this.#byOutput.delete(output);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
size() {
|
|
22
|
+
return this.#byOutput.size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
segmentsOf(output, sessionInit, inventory, positionSeconds) {
|
|
26
|
+
const timeline = output.timeline;
|
|
27
|
+
const count = timeline?.segmentCount ?? 0;
|
|
28
|
+
let forecast = this.#byOutput.get(output);
|
|
29
|
+
if (!forecast) {
|
|
30
|
+
forecast = { pieces: [], geometry: {} };
|
|
31
|
+
this.#byOutput.set(output, forecast);
|
|
32
|
+
}
|
|
33
|
+
const kept = forecast.pieces;
|
|
34
|
+
const segments = [];
|
|
35
|
+
const lookahead = Number(this.#host.lookaheadSeconds);
|
|
36
|
+
const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
|
|
37
|
+
const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
|
|
38
|
+
let low = 0, high = count;
|
|
39
|
+
while (low < high) {
|
|
40
|
+
const middle = (low + high) >>> 1;
|
|
41
|
+
if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
|
|
42
|
+
else high = middle;
|
|
43
|
+
}
|
|
44
|
+
const first = Math.max(0, low - 1);
|
|
45
|
+
low = first;
|
|
46
|
+
high = count;
|
|
47
|
+
while (low < high) {
|
|
48
|
+
const middle = (low + high) >>> 1;
|
|
49
|
+
if (timeline.publishedStartOf(middle) < end) low = middle + 1;
|
|
50
|
+
else high = middle;
|
|
51
|
+
}
|
|
52
|
+
const last = low;
|
|
53
|
+
const indexes = new Set([0]);
|
|
54
|
+
for (let index = first; index < last; index++) indexes.add(index);
|
|
55
|
+
for (const index of indexes) {
|
|
56
|
+
const endSeconds = timeline.publishedStartOf(index + 1);
|
|
57
|
+
const ranges = inventory.has(index) ? this.#host.finishedMediaRangesOf(output, index) : undefined;
|
|
58
|
+
const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
|
|
59
|
+
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
|
|
60
|
+
if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
|
|
61
|
+
Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) &&
|
|
62
|
+
!this.#host.endsWithinPlayerSeekTolerance(endSeconds, positionSeconds) &&
|
|
63
|
+
endSeconds < positionSeconds) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const startSeconds = timeline.publishedStartOf(index);
|
|
67
|
+
const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
|
|
68
|
+
const previous = kept[index];
|
|
69
|
+
if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
|
|
70
|
+
previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
|
|
71
|
+
segments.push(previous);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
|
|
75
|
+
kept[index] = segment;
|
|
76
|
+
segments.push(segment);
|
|
77
|
+
}
|
|
78
|
+
if (kept.length > count) kept.length = count;
|
|
79
|
+
return segments;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sameInputs(left, right) {
|
|
84
|
+
if (left === right) return true;
|
|
85
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
86
|
+
return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
|
|
87
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-request measurements owned by one live viewing. A viewer is the key,
|
|
3
|
+
* rather than its reusable wire id, so a later viewing with the same id starts
|
|
4
|
+
* with fresh measurements.
|
|
5
|
+
*/
|
|
6
|
+
export class ViewerPlaybackState {
|
|
7
|
+
#byViewer = new Map();
|
|
8
|
+
#waitOrder = new Map();
|
|
9
|
+
|
|
10
|
+
#stateFor(viewer, create = false) {
|
|
11
|
+
if (!viewer || typeof viewer !== "object") return null;
|
|
12
|
+
let state = this.#byViewer.get(viewer);
|
|
13
|
+
if (!state && create) {
|
|
14
|
+
state = { ratesByOutput: new Map(), waits: new Map() };
|
|
15
|
+
this.#byViewer.set(viewer, state);
|
|
16
|
+
}
|
|
17
|
+
return state ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
rateState(viewer, output, playbackReadiness, lookaheadSeconds) {
|
|
21
|
+
const owner = this.#stateFor(viewer, true);
|
|
22
|
+
let state = owner.ratesByOutput.get(output);
|
|
23
|
+
if (!state) {
|
|
24
|
+
state = { link: new playbackReadiness.RateTrend(lookaheadSeconds), sources: new Map() };
|
|
25
|
+
owner.ratesByOutput.set(output, state);
|
|
26
|
+
}
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
owns(viewer, output, state) {
|
|
31
|
+
return this.#stateFor(viewer)?.ratesByOutput.get(output) === state;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
forgetOutput(output) {
|
|
35
|
+
for (const [viewer, owner] of this.#byViewer) {
|
|
36
|
+
owner.ratesByOutput.delete(output);
|
|
37
|
+
this.#deleteIfEmpty(viewer, owner);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
forgetViewerOutput(viewer, output) {
|
|
42
|
+
const owner = this.#stateFor(viewer);
|
|
43
|
+
if (!owner) return false;
|
|
44
|
+
const removed = owner.ratesByOutput.delete(output);
|
|
45
|
+
this.#deleteIfEmpty(viewer, owner);
|
|
46
|
+
return removed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
addLinkReading(state, at, mbps, capture = null) {
|
|
50
|
+
state.link.add(at, mbps * 1_000_000, capture);
|
|
51
|
+
}
|
|
52
|
+
|
|
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) {
|
|
58
|
+
let source = state.sources.get(id);
|
|
59
|
+
if (!source) {
|
|
60
|
+
const { RateTrend, SessionRate } = playbackReadiness;
|
|
61
|
+
source = { delivered: new RateTrend(lookaheadSeconds), free: new RateTrend(lookaheadSeconds),
|
|
62
|
+
deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
|
|
63
|
+
state.sources.set(id, source);
|
|
64
|
+
}
|
|
65
|
+
const readingCapture = capture ?? stats?.sourceCapture ?? null;
|
|
66
|
+
if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
|
|
67
|
+
source.delivered.add(now, stats.downloadSpeed, readingCapture);
|
|
68
|
+
source.deliveredAll.add(now, stats.downloadSpeed, readingCapture);
|
|
69
|
+
}
|
|
70
|
+
const limit = stats?.selectionLimit;
|
|
71
|
+
if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
|
|
72
|
+
const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
|
|
73
|
+
source.free.add(now, free, readingCapture);
|
|
74
|
+
source.freeAll.add(now, free, readingCapture);
|
|
75
|
+
}
|
|
76
|
+
if (limit) source.limit = limit;
|
|
77
|
+
return sourceService(source.free.snapshot(), source.delivered.snapshot(),
|
|
78
|
+
summary => playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
sourceTailRateReadings(state, id, now, playbackReadiness) {
|
|
82
|
+
const source = state.sources.get(id);
|
|
83
|
+
if (!source) return null;
|
|
84
|
+
return sourceService(source.freeAll.snapshot(), source.deliveredAll.snapshot(),
|
|
85
|
+
summary => playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
sourceRateSummaries(state, id) {
|
|
89
|
+
const source = state.sources.get(id);
|
|
90
|
+
return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
|
|
91
|
+
}
|
|
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
|
+
|
|
106
|
+
getWait(viewer, waitId) {
|
|
107
|
+
return this.#stateFor(viewer)?.waits.get(waitId) ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
hasWait(viewer, waitId) {
|
|
111
|
+
return this.#stateFor(viewer)?.waits.has(waitId) ?? false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features }) {
|
|
115
|
+
const owner = this.#stateFor(viewer, true);
|
|
116
|
+
const current = owner.waits.get(waitId);
|
|
117
|
+
const context = current?.waitId === waitId ? current : {
|
|
118
|
+
consumerId, generation, sourceKey, fileIndex, waitId, snapshots: [], observedWaits: new Set()
|
|
119
|
+
};
|
|
120
|
+
const measuredAt = performance.now();
|
|
121
|
+
const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
|
|
122
|
+
? Math.max(0, measuredAt - context.featuresMeasuredAt) : 0;
|
|
123
|
+
const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
|
|
124
|
+
Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
|
|
125
|
+
featuresMeasuredAt: measuredAt, updatedAt: Date.now() });
|
|
126
|
+
const previous = context.snapshots.at(-1);
|
|
127
|
+
if (!previous || JSON.stringify(previous) !== JSON.stringify(mergedFeatures)) {
|
|
128
|
+
const snapshot = { ...mergedFeatures };
|
|
129
|
+
if (context.snapshots.length < 2) context.snapshots.push(snapshot);
|
|
130
|
+
else context.snapshots[1] = snapshot;
|
|
131
|
+
}
|
|
132
|
+
owner.waits.set(waitId, context);
|
|
133
|
+
if (!this.#waitOrder.has(context)) this.#waitOrder.set(context, { viewer, waitId });
|
|
134
|
+
return context;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
noteFirstFrame({ viewer, generation, waitId, wait, elapsedMs, stages, record }) {
|
|
138
|
+
const observation = this.getWait(viewer, waitId);
|
|
139
|
+
if (!observation || observation.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 ||
|
|
140
|
+
observation.observedWaits.has(wait) || !Number.isFinite(elapsedMs) || elapsedMs < 0) return false;
|
|
141
|
+
const initialFeatures = observation.snapshots[0];
|
|
142
|
+
if (initialFeatures) record({ seconds: elapsedMs / 1000, features: initialFeatures,
|
|
143
|
+
featureSnapshots: observation.snapshots.slice(1), stages });
|
|
144
|
+
observation.observedWaits.add(wait);
|
|
145
|
+
observation.updatedAt = Date.now();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
deleteWait(viewer, waitId) {
|
|
150
|
+
const owner = this.#stateFor(viewer);
|
|
151
|
+
if (!owner) return false;
|
|
152
|
+
const context = owner.waits.get(waitId);
|
|
153
|
+
const removed = owner.waits.delete(waitId);
|
|
154
|
+
if (context) this.#waitOrder.delete(context);
|
|
155
|
+
this.#deleteIfEmpty(viewer, owner);
|
|
156
|
+
return removed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pruneWaits(now = Date.now(), maxObservations = 1000) {
|
|
160
|
+
const cutoff = now - 30 * 60_000;
|
|
161
|
+
for (const [viewer, owner] of this.#byViewer) {
|
|
162
|
+
for (const [waitId, value] of owner.waits) {
|
|
163
|
+
if (value.updatedAt < cutoff) {
|
|
164
|
+
owner.waits.delete(waitId);
|
|
165
|
+
this.#waitOrder.delete(value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (owner) this.#deleteIfEmpty(viewer, owner);
|
|
169
|
+
}
|
|
170
|
+
while (this.#waitOrder.size > maxObservations) {
|
|
171
|
+
const [context, { viewer, waitId }] = this.#waitOrder.entries().next().value;
|
|
172
|
+
const owner = this.#stateFor(viewer);
|
|
173
|
+
owner?.waits.delete(waitId);
|
|
174
|
+
this.#waitOrder.delete(context);
|
|
175
|
+
if (owner) this.#deleteIfEmpty(viewer, owner);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
forgetViewer(viewer) {
|
|
180
|
+
const owner = this.#stateFor(viewer);
|
|
181
|
+
if (!owner) return false;
|
|
182
|
+
for (const context of owner.waits.values()) this.#waitOrder.delete(context);
|
|
183
|
+
return this.#byViewer.delete(viewer);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
viewerCount() {
|
|
187
|
+
return this.#byViewer.size;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
waitCount(viewer) {
|
|
191
|
+
return this.#stateFor(viewer)?.waits.size ?? 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#deleteIfEmpty(viewer, owner) {
|
|
195
|
+
if (owner.ratesByOutput.size === 0 && owner.waits.size === 0) this.#byViewer.delete(viewer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function sourceService(free, delivered, rateOf) {
|
|
200
|
+
if (!free) return delivered;
|
|
201
|
+
if (!delivered) return free;
|
|
202
|
+
return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mergeWaitFeatures(previous, incoming, ageElapsedMs) {
|
|
206
|
+
const merged = { ...(previous ?? {}) };
|
|
207
|
+
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
208
|
+
if (value === null || value === undefined) {
|
|
209
|
+
if (!Object.hasOwn(merged, key)) merged[key] = null;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (key === "extension") {
|
|
213
|
+
if (typeof value === "string" && value.length > 0) merged[key] = value;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (key === "fileAvailable") {
|
|
217
|
+
if (typeof value === "boolean") merged[key] = value;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (key === "videoOperation" || key === "audioOperation") {
|
|
221
|
+
if (value === "copy" || value === "encode") merged[key] = value;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 ||
|
|
225
|
+
((key === "fileLength" || key === "linkMbps") && value === 0)) continue;
|
|
226
|
+
merged[key] = value;
|
|
227
|
+
}
|
|
228
|
+
const hasIncomingAge = Number.isFinite(incoming?.linkSampleAgeMs) && incoming.linkSampleAgeMs >= 0;
|
|
229
|
+
if (Number.isFinite(merged.linkSampleAgeMs) && !hasIncomingAge) {
|
|
230
|
+
merged.linkSampleAgeMs += Number.isFinite(ageElapsedMs) ? Math.max(0, ageElapsedMs) : 0;
|
|
231
|
+
}
|
|
232
|
+
return merged;
|
|
233
|
+
}
|