@torrent-tv/proxy 2.107.47 → 2.107.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +10 -1
- package/routes/api/transcode-sessions/net-report/post.js +11 -1
- package/routes/api/transcode-sessions/progress/get.js +7 -0
- package/services/server/OutputLifecycle.js +1 -0
- package/services/server/ViewerRequests.js +58 -6
- package/services/server/wire-outputs.js +15 -1
- package/services/storage/PlaybackStorageDemand.js +64 -0
- package/services/storage/wire.js +18 -2
- package/services/viewer/Viewer.js +130 -0
- package/services/viewer/playback-readiness.js +119 -9
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { performance } from "node:perf_hooks";
|
|
|
20
20
|
* @returns {Promise<void>}
|
|
21
21
|
*/
|
|
22
22
|
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests }) {
|
|
23
|
+
const requestReceivedAtProxyMs = performance.now();
|
|
23
24
|
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
24
25
|
if (!sourceKey) {
|
|
25
26
|
return reply.code(400).send({ error: "sourceKey is required." });
|
|
@@ -67,10 +68,12 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
67
68
|
// local lookup. Without the await the reply was the pending promise itself,
|
|
68
69
|
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
69
70
|
const statsStartedAt = performance.now();
|
|
71
|
+
let statsCompletedAt;
|
|
70
72
|
let stats;
|
|
71
73
|
try {
|
|
72
74
|
stats = await torrentPool.getFileStats(torrent, fileIndex);
|
|
73
75
|
} finally {
|
|
76
|
+
statsCompletedAt = performance.now();
|
|
74
77
|
logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
|
|
75
78
|
}
|
|
76
79
|
if (stats?.pending || !stats) {
|
|
@@ -154,5 +157,11 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
154
157
|
fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
|
|
155
158
|
stats, ...earlyLink });
|
|
156
159
|
}
|
|
157
|
-
|
|
160
|
+
const responsePreparedAtProxyMs = performance.now();
|
|
161
|
+
return reply.send({ ...stats, sourceSnapshotTransport: {
|
|
162
|
+
requestReceivedAtProxyMs,
|
|
163
|
+
snapshotStartedAtProxyMs: statsStartedAt,
|
|
164
|
+
snapshotCompletedAtProxyMs: statsCompletedAt,
|
|
165
|
+
responsePreparedAtProxyMs
|
|
166
|
+
} });
|
|
158
167
|
}
|
|
@@ -8,7 +8,9 @@ import { recordViewerReport } from "../../../../services/viewer/report-intake.js
|
|
|
8
8
|
*
|
|
9
9
|
* POST /api/transcode-sessions/:sessionId/net-report
|
|
10
10
|
* Body: { consumerId: string, bufferedAheadSec: number, linkMbps?: number,
|
|
11
|
-
* positionSeconds?: number, playingHeight?: number,
|
|
11
|
+
* positionSeconds?: number, playingHeight?: number, appendEpoch?: string,
|
|
12
|
+
* appendEpochStartedAt?: number, appendSnapshotAt?: number,
|
|
13
|
+
* appendService?: object, fragmentService?: object, … }
|
|
12
14
|
*
|
|
13
15
|
* `consumerId` and `positionSeconds` say WHO is reporting and WHERE they are.
|
|
14
16
|
* A copied picture is one session shared by every viewer of it, so without them
|
|
@@ -115,6 +117,14 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
|
|
|
115
117
|
linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
|
|
116
118
|
linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
|
|
117
119
|
linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
|
|
120
|
+
appendSessionId: sessionId,
|
|
121
|
+
appendEpoch: typeof body.appendEpoch === "string" ? body.appendEpoch : undefined,
|
|
122
|
+
appendEpochStartedAt: Number.isFinite(body.appendEpochStartedAt) && body.appendEpochStartedAt > 0
|
|
123
|
+
? body.appendEpochStartedAt : undefined,
|
|
124
|
+
appendSnapshotAt: Number.isFinite(body.appendSnapshotAt) && body.appendSnapshotAt > 0
|
|
125
|
+
? body.appendSnapshotAt : undefined,
|
|
126
|
+
appendService: body.appendService && typeof body.appendService === "object" ? body.appendService : undefined,
|
|
127
|
+
fragmentService: body.fragmentService && typeof body.fragmentService === "object" ? body.fragmentService : undefined,
|
|
118
128
|
bufferedAheadSec,
|
|
119
129
|
bufferedRanges: body.bufferedRanges,
|
|
120
130
|
timestampOffsets: body.bufferedRanges?.timestampOffsets,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @returns {Promise<void>}
|
|
10
10
|
*/
|
|
11
11
|
export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests }) {
|
|
12
|
+
const requestReceivedAtProxyMs = performance.now();
|
|
12
13
|
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
13
14
|
if (!sessionId) {
|
|
14
15
|
return reply.code(400).send({ error: "sessionId is required." });
|
|
@@ -23,5 +24,11 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewer
|
|
|
23
24
|
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
const responsePreparedAtProxyMs = performance.now();
|
|
28
|
+
progress.snapshotTransport = {
|
|
29
|
+
requestReceivedAtProxyMs,
|
|
30
|
+
snapshotAtProxyMs: progress.playbackReadiness?.snapshotAtProxyMs ?? null,
|
|
31
|
+
responsePreparedAtProxyMs
|
|
32
|
+
};
|
|
26
33
|
return reply.send(progress);
|
|
27
34
|
}
|
|
@@ -427,6 +427,7 @@ export class OutputLifecycle {
|
|
|
427
427
|
if (!output) {
|
|
428
428
|
return false;
|
|
429
429
|
}
|
|
430
|
+
this.#host.releasePlaybackStorageDemand?.(output.outputKey ?? "", consumerId);
|
|
430
431
|
// Nothing to release in the encoding: it holds one map per output, built
|
|
431
432
|
// from where the viewers are, and the map that arrives next simply does not
|
|
432
433
|
// have this one in it. A name to release was the last place a viewer
|
|
@@ -41,6 +41,36 @@ function sameInputs(left, right) {
|
|
|
41
41
|
return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function browserServiceSummary(value) {
|
|
45
|
+
if (!value || typeof value !== "object") return null;
|
|
46
|
+
return Object.fromEntries(["init", "media"].filter(kind => value[kind]).map(kind => [kind, value[kind]]).map(([kind, service]) => [kind, {
|
|
47
|
+
bytes: service.bytes,
|
|
48
|
+
elapsedMs: service.elapsedMs,
|
|
49
|
+
operations: service.operations,
|
|
50
|
+
sampleCount: service.samples.length,
|
|
51
|
+
newestSampleAgeMs: service.samples.reduce((age, sample) => age === null ? sample.ageMs : Math.min(age, sample.ageMs), null),
|
|
52
|
+
samples: service.samples.map(({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence, partIndex, fragmentStartSeconds,
|
|
53
|
+
parseElapsedMs, bufferingElapsedMs, bufferingAgeMs, eventAgesMs }) => ({ bytes, elapsedMs, ageMs, fragmentId,
|
|
54
|
+
fragmentSequence, partIndex, fragmentStartSeconds,
|
|
55
|
+
...(parseElapsedMs === null || parseElapsedMs === undefined ? {} : { parseElapsedMs }),
|
|
56
|
+
...(bufferingElapsedMs === null || bufferingElapsedMs === undefined ? {} : { bufferingElapsedMs }),
|
|
57
|
+
...(bufferingAgeMs === null || bufferingAgeMs === undefined ? {} : { bufferingAgeMs }),
|
|
58
|
+
...(eventAgesMs ? { eventAgesMs } : {}) }))
|
|
59
|
+
}]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function browserServiceFingerprint(tracks) {
|
|
63
|
+
const serviceFingerprint = service => service ? ["init", "media"].map(kind => {
|
|
64
|
+
const value = service[kind];
|
|
65
|
+
return value ? [kind, value.bytes, value.elapsedMs, value.operations, value.samples.map(sample => [
|
|
66
|
+
sample.bytes, sample.elapsedMs, sample.fragmentId, sample.fragmentSequence, sample.partIndex,
|
|
67
|
+
sample.fragmentStartSeconds, sample.parseElapsedMs, sample.bufferingElapsedMs
|
|
68
|
+
])] : [kind, null];
|
|
69
|
+
}) : null;
|
|
70
|
+
return JSON.stringify(tracks.map(track => [track.id,
|
|
71
|
+
serviceFingerprint(track.appendService), serviceFingerprint(track.fragmentService)]));
|
|
72
|
+
}
|
|
73
|
+
|
|
44
74
|
export function forecastInputState(tracks, identity) {
|
|
45
75
|
if (!identity) return null;
|
|
46
76
|
const track = tracks.find(candidate => candidate.id === identity.trackId);
|
|
@@ -912,6 +942,19 @@ export class ViewerRequests {
|
|
|
912
942
|
clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
|
|
913
943
|
clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
914
944
|
viewerReading?.bufferedRanges?.media,
|
|
945
|
+
// Browser-observed append and fragment-parse samples are retained for
|
|
946
|
+
// the full model's service schedule; they are not yet used as numeric
|
|
947
|
+
// predecessors in the partial continuity calculation.
|
|
948
|
+
appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
949
|
+
? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
|
|
950
|
+
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
951
|
+
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
952
|
+
: null,
|
|
953
|
+
fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
954
|
+
? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
|
|
955
|
+
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
956
|
+
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
957
|
+
: null,
|
|
915
958
|
sourceIds,
|
|
916
959
|
processedSeconds: trackProgress?.processedSeconds,
|
|
917
960
|
startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
|
|
@@ -970,9 +1013,8 @@ export class ViewerRequests {
|
|
|
970
1013
|
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
971
1014
|
reserveSeconds,
|
|
972
1015
|
lookaheadSeconds: this.#host.lookaheadSeconds,
|
|
973
|
-
//
|
|
974
|
-
|
|
975
|
-
sourceStorageBytes: this.#host.sourceStorageBytes?.() ?? null,
|
|
1016
|
+
// Disk room remaining after stock reserved for other active outputs.
|
|
1017
|
+
sourceStorageBytes: this.#host.sourceStorageBytes?.(session.outputKey ?? session.id) ?? null,
|
|
976
1018
|
requiredAudio: requiresSeparateAudio,
|
|
977
1019
|
sources: [...sourceMeasurements.values()],
|
|
978
1020
|
tracks,
|
|
@@ -985,6 +1027,9 @@ export class ViewerRequests {
|
|
|
985
1027
|
viewer.subtitle.fileIndex === session.file.fileIndex ? viewer.subtitle : null;
|
|
986
1028
|
const startedAt = performance.now();
|
|
987
1029
|
const predicted = this.#host.playbackReadiness.predict(input);
|
|
1030
|
+
if (consumerId && viewer && this.#host.viewers.get(consumerId) === viewer && Number.isFinite(predicted.cushionBytes)) {
|
|
1031
|
+
this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
|
|
1032
|
+
}
|
|
988
1033
|
const predictMs = performance.now() - startedAt;
|
|
989
1034
|
this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
|
|
990
1035
|
logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
|
|
@@ -992,6 +1037,7 @@ export class ViewerRequests {
|
|
|
992
1037
|
`start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
|
|
993
1038
|
const forecast = withSubtitleReadiness(predicted, subtitle,
|
|
994
1039
|
subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
|
|
1040
|
+
forecast.snapshotAtProxyMs = startedAt;
|
|
995
1041
|
forecast.operations = outputs.flatMap((output, index) => {
|
|
996
1042
|
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
|
|
997
1043
|
processedSeconds: tracks[index].processedSeconds,
|
|
@@ -1049,9 +1095,13 @@ export class ViewerRequests {
|
|
|
1049
1095
|
const forecastInputKey = unavailableInput ? JSON.stringify([
|
|
1050
1096
|
unavailableInput.sourceId, unavailableInput.trackId, unavailableInput.segmentIndex, unavailableInput.phase
|
|
1051
1097
|
]) : null;
|
|
1052
|
-
|
|
1098
|
+
const browserEvidenceKey = browserServiceFingerprint(tracks);
|
|
1099
|
+
const browserEvidenceChanged = measurement.browserEvidenceKey !== browserEvidenceKey;
|
|
1100
|
+
if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey ||
|
|
1101
|
+
browserEvidenceChanged) {
|
|
1053
1102
|
measurement.forecastReason = forecast.reason;
|
|
1054
1103
|
measurement.forecastInputKey = forecastInputKey;
|
|
1104
|
+
measurement.browserEvidenceKey = browserEvidenceKey;
|
|
1055
1105
|
if (forecast.subtitles) this.#host.observeWait?.(session.file.sourceKey, "process", {
|
|
1056
1106
|
process: "C3", phase: "event", fileIndex: session.file.fileIndex,
|
|
1057
1107
|
trackIndex: forecast.subtitles.trackIndex, ready: forecast.subtitles.ready,
|
|
@@ -1073,8 +1123,10 @@ export class ViewerRequests {
|
|
|
1073
1123
|
downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
|
|
1074
1124
|
downloadRateSummaries
|
|
1075
1125
|
})),
|
|
1076
|
-
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
1077
|
-
id, processedSeconds, readings,
|
|
1126
|
+
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices, appendService, fragmentService }) => ({
|
|
1127
|
+
id, processedSeconds, readings, appendService: browserServiceSummary(appendService),
|
|
1128
|
+
fragmentService: browserServiceSummary(fragmentService), segments: segments.length,
|
|
1129
|
+
prepared: readySegmentIndices.length
|
|
1078
1130
|
}))
|
|
1079
1131
|
})}`);
|
|
1080
1132
|
}
|
|
@@ -95,6 +95,7 @@ import { audioReadingFor } from "../encode/audio-work-rate.js";
|
|
|
95
95
|
import { wireMachineBudget } from "../storage/wire.js";
|
|
96
96
|
import { Returns } from "../storage/returns.js";
|
|
97
97
|
import { freeBytesFor } from "../storage/free.js";
|
|
98
|
+
import { PlaybackStorageDemand } from "../storage/PlaybackStorageDemand.js";
|
|
98
99
|
import { inputCacheRoot, probeInputCache } from "../encode/input-cache.js";
|
|
99
100
|
import path from "node:path";
|
|
100
101
|
import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
|
|
@@ -579,6 +580,7 @@ export function wireOutputs({
|
|
|
579
580
|
get encodeRuns() { return parts.encodeRuns; },
|
|
580
581
|
get keyframeTables() { return parts.keyframeTables; },
|
|
581
582
|
get machineBudget() { return parts.machineBudget; },
|
|
583
|
+
releasePlaybackStorageDemand: (key, consumerId) => parts.playbackStorageDemand.release(key, consumerId),
|
|
582
584
|
get outputTimes() { return parts.outputTimes; },
|
|
583
585
|
get outputs() { return parts.outputs; },
|
|
584
586
|
get returns() { return parts.returns; },
|
|
@@ -629,7 +631,13 @@ export function wireOutputs({
|
|
|
629
631
|
viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
|
|
630
632
|
minimumBufferSecondsFor: (output) => minimumBufferSecondsOf(output),
|
|
631
633
|
// What this proxy may keep of downloaded pieces on disk.
|
|
632
|
-
sourceStorageBytes:
|
|
634
|
+
sourceStorageBytes: outputKey => {
|
|
635
|
+
const capacity = parts.machineBudget?.storageBytes();
|
|
636
|
+
return Number.isFinite(capacity)
|
|
637
|
+
? Math.max(0, capacity - parts.playbackStorageDemand.bytesExcept(outputKey))
|
|
638
|
+
: null;
|
|
639
|
+
},
|
|
640
|
+
notePlaybackStorageDemand: (key, consumerId, bytes) => parts.playbackStorageDemand.note(key, consumerId, bytes),
|
|
633
641
|
getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
|
|
634
642
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
635
643
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
@@ -1173,6 +1181,11 @@ export function wireOutputs({
|
|
|
1173
1181
|
// one term of the keeping period that is guessed rather than measured, and
|
|
1174
1182
|
// the only place it can be measured from.
|
|
1175
1183
|
parts.returns = new Returns();
|
|
1184
|
+
parts.playbackStorageDemand = new PlaybackStorageDemand(() => {
|
|
1185
|
+
if (parts.machineBudget) {
|
|
1186
|
+
void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1176
1189
|
parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
|
|
1177
1190
|
resolve: resolveEncodeInput,
|
|
1178
1191
|
readRanges: readEncodeRanges,
|
|
@@ -1221,6 +1234,7 @@ export function wireOutputs({
|
|
|
1221
1234
|
// FFmpeg's cache keeps its file in /tmp, or where FFmpeg runs on a build
|
|
1222
1235
|
// without `mkstemp` — an output directory of the segments.
|
|
1223
1236
|
inputCaches: { held: () => parts.encodeRuns?.inputCacheBytes() ?? 0, root: inputCacheRoot() },
|
|
1237
|
+
playbackStorage: parts.playbackStorageDemand,
|
|
1224
1238
|
policy: parts.budgetPolicy ?? undefined,
|
|
1225
1239
|
readFree: freeBytesFor,
|
|
1226
1240
|
logger
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bytes an active playback forecast says must remain available on disk.
|
|
3
|
+
* Viewers of one output share its stored bytes, so its demand is the largest
|
|
4
|
+
* live viewer demand; distinct outputs add their requirements.
|
|
5
|
+
*/
|
|
6
|
+
export class PlaybackStorageDemand {
|
|
7
|
+
#byOutput = new Map();
|
|
8
|
+
#changed;
|
|
9
|
+
|
|
10
|
+
constructor(changed = () => {}) {
|
|
11
|
+
this.#changed = changed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @returns {number} */
|
|
15
|
+
bytes() {
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const viewers of this.#byOutput.values()) {
|
|
18
|
+
let shared = 0;
|
|
19
|
+
for (const bytes of viewers.values()) shared = Math.max(shared, bytes);
|
|
20
|
+
total += shared;
|
|
21
|
+
}
|
|
22
|
+
return total;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} outputKey @returns {number} */
|
|
26
|
+
bytesExcept(outputKey) {
|
|
27
|
+
let total = 0;
|
|
28
|
+
for (const [key, viewers] of this.#byOutput) {
|
|
29
|
+
if (key === outputKey) continue;
|
|
30
|
+
let shared = 0;
|
|
31
|
+
for (const bytes of viewers.values()) shared = Math.max(shared, bytes);
|
|
32
|
+
total += shared;
|
|
33
|
+
}
|
|
34
|
+
return total;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {string} outputKey @param {string} consumerId @param {number} bytes */
|
|
38
|
+
note(outputKey, consumerId, bytes) {
|
|
39
|
+
if (!outputKey || !consumerId || !Number.isFinite(bytes) || bytes < 0) return;
|
|
40
|
+
const before = this.bytes();
|
|
41
|
+
let viewers = this.#byOutput.get(outputKey);
|
|
42
|
+
if (!viewers) {
|
|
43
|
+
viewers = new Map();
|
|
44
|
+
this.#byOutput.set(outputKey, viewers);
|
|
45
|
+
}
|
|
46
|
+
viewers.set(consumerId, bytes);
|
|
47
|
+
this.#notifyIfChanged(before);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} outputKey @param {string} consumerId */
|
|
51
|
+
release(outputKey, consumerId) {
|
|
52
|
+
const viewers = this.#byOutput.get(outputKey);
|
|
53
|
+
if (!viewers?.has(consumerId)) return;
|
|
54
|
+
const before = this.bytes();
|
|
55
|
+
viewers.delete(consumerId);
|
|
56
|
+
if (viewers.size === 0) this.#byOutput.delete(outputKey);
|
|
57
|
+
this.#notifyIfChanged(before);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#notifyIfChanged(before) {
|
|
61
|
+
const after = this.bytes();
|
|
62
|
+
if (after !== before) this.#changed(after);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/services/storage/wire.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* segments an encoder produced, the files downloaded whole — and the evidence,
|
|
11
11
|
* which is a claimant with a rule of its own (`Diagnostics.js`). Three of the
|
|
12
12
|
* five live on the torrent thread, so they arrive as pairs of closures over its
|
|
13
|
-
* channel rather than as the objects themselves.
|
|
13
|
+
* channel rather than as the objects themselves. Active playback also
|
|
14
|
+
* reserves its forecasted source stock here; that is a requirement, not a
|
|
15
|
+
* store holding bytes yet.
|
|
14
16
|
*
|
|
15
17
|
* **Which RESOURCE each takes is read, not assumed.** Memory is one. Disk is
|
|
16
18
|
* one per device, because two filesystems cannot pay for each other: measured
|
|
@@ -56,7 +58,7 @@ import { availableMemoryBytes } from "./machine-memory.js";
|
|
|
56
58
|
* @param {import("./MachineBudget.js").BudgetPolicy} [params.policy] - What the
|
|
57
59
|
* operator has said this proxy may take. Absent, the measured default.
|
|
58
60
|
* @param {{ info: Function, warn?: Function }} [params.logger]
|
|
59
|
-
* @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number | null, diagnostics: object | null, describe: () => string }}
|
|
61
|
+
* @returns {{ revise: (resource?: string | null, because?: string | null) => Promise<unknown>, segmentBytes: () => number | null, storageBytes: () => number | null, storageResource: string, diagnostics: object | null, describe: () => string }}
|
|
60
62
|
* What the segments may hold is asked for rather than pushed: null until the
|
|
61
63
|
* first revision, then the measured allowance, including a measured zero.
|
|
62
64
|
*/
|
|
@@ -68,6 +70,7 @@ export function wireMachineBudget({
|
|
|
68
70
|
indexMemory,
|
|
69
71
|
wholeFiles,
|
|
70
72
|
inputCaches,
|
|
73
|
+
playbackStorage,
|
|
71
74
|
diagnostics,
|
|
72
75
|
diagnosticsRoot = "",
|
|
73
76
|
readFree,
|
|
@@ -177,6 +180,17 @@ export function wireMachineBudget({
|
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
if (typeof playbackStorage?.bytes === "function") {
|
|
184
|
+
budget.register({
|
|
185
|
+
name: "playback stock",
|
|
186
|
+
resource: segmentsOn,
|
|
187
|
+
held: () => 0,
|
|
188
|
+
wanted: () => playbackStorage.bytes(),
|
|
189
|
+
required: () => playbackStorage.bytes(),
|
|
190
|
+
allow: () => {}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
let evidenceOn = null;
|
|
181
195
|
if (diagnostics) {
|
|
182
196
|
evidenceOn = diagnosticsRoot ? deviceOf(diagnosticsRoot) : segmentsOn;
|
|
@@ -194,6 +208,8 @@ export function wireMachineBudget({
|
|
|
194
208
|
|
|
195
209
|
return {
|
|
196
210
|
capacityOf: name => budget.capacityOf(name),
|
|
211
|
+
storageBytes: () => budget.capacityOf(segmentsOn),
|
|
212
|
+
storageResource: segmentsOn,
|
|
197
213
|
/** What one claimant was last allowed, or null before a measured division. */
|
|
198
214
|
allowedFor: name => budget.allowedFor(name),
|
|
199
215
|
/**
|
|
@@ -43,6 +43,104 @@
|
|
|
43
43
|
import { Assignments } from "./Assignments.js";
|
|
44
44
|
import { bufferTrend, trendRunStart } from "./buffer-trend.js";
|
|
45
45
|
|
|
46
|
+
const APPEND_TRACKS = ["video", "audio"];
|
|
47
|
+
const APPEND_KINDS = ["init", "media"];
|
|
48
|
+
const FRAGMENT_EVENT_AGES = ["loadStart", "loadFirstByte", "loadEnd", "parseStart", "parseEnd",
|
|
49
|
+
"bufferingStart", "bufferingEnd"];
|
|
50
|
+
|
|
51
|
+
function fragmentEventAges(value) {
|
|
52
|
+
return Object.fromEntries(FRAGMENT_EVENT_AGES.map(name => [name,
|
|
53
|
+
Number.isFinite(value?.[name]) && value[name] >= 0 ? value[name] : null]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function appendServiceSnapshot(value) {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
58
|
+
const snapshot = {};
|
|
59
|
+
for (const track of APPEND_TRACKS) {
|
|
60
|
+
if (!value[track] || typeof value[track] !== "object" || Array.isArray(value[track])) return null;
|
|
61
|
+
snapshot[track] = {};
|
|
62
|
+
for (const kind of APPEND_KINDS) {
|
|
63
|
+
const item = value[track][kind];
|
|
64
|
+
if (!item || !Number.isSafeInteger(item.bytes) || item.bytes < 0 || !Number.isSafeInteger(item.operations) ||
|
|
65
|
+
item.operations < 0 || !Number.isFinite(item.elapsedMs) || item.elapsedMs < 0 ||
|
|
66
|
+
(item.operations === 0 && (item.bytes > 0 || item.elapsedMs > 0)) ||
|
|
67
|
+
(item.operations > 0 && (item.bytes === 0 || item.elapsedMs === 0)) || !Array.isArray(item.samples) ||
|
|
68
|
+
!item.samples.every(sample => Number.isSafeInteger(sample?.bytes) && sample.bytes > 0 &&
|
|
69
|
+
Number.isFinite(sample.elapsedMs) && sample.elapsedMs > 0 &&
|
|
70
|
+
Number.isFinite(sample.ageMs) && sample.ageMs >= 0 &&
|
|
71
|
+
(sample.fragmentId === null || typeof sample.fragmentId === "string" && sample.fragmentId.length <= 128) &&
|
|
72
|
+
(sample.fragmentSequence === undefined || sample.fragmentSequence === null ||
|
|
73
|
+
Number.isSafeInteger(sample.fragmentSequence) && sample.fragmentSequence >= 0) &&
|
|
74
|
+
(sample.partIndex === undefined || sample.partIndex === null ||
|
|
75
|
+
Number.isSafeInteger(sample.partIndex) && sample.partIndex >= 0) &&
|
|
76
|
+
(sample.fragmentStartSeconds === null || Number.isFinite(sample.fragmentStartSeconds)))) return null;
|
|
77
|
+
snapshot[track][kind] = { bytes: item.bytes, elapsedMs: item.elapsedMs, operations: item.operations,
|
|
78
|
+
samples: item.samples.map(({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence, partIndex,
|
|
79
|
+
fragmentStartSeconds }) => ({ bytes, elapsedMs, ageMs, fragmentId,
|
|
80
|
+
fragmentSequence: fragmentSequence ?? null, partIndex: partIndex ?? null, fragmentStartSeconds })) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return snapshot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fragmentServiceSnapshot(value) {
|
|
87
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
88
|
+
const snapshot = {};
|
|
89
|
+
for (const track of APPEND_TRACKS) {
|
|
90
|
+
if (!value[track] || typeof value[track] !== "object" || Array.isArray(value[track])) return null;
|
|
91
|
+
snapshot[track] = {};
|
|
92
|
+
for (const kind of APPEND_KINDS) {
|
|
93
|
+
const item = value[track][kind];
|
|
94
|
+
if (!item || typeof item !== "object" || Array.isArray(item) || !Number.isSafeInteger(item.bytes) || item.bytes < 0 ||
|
|
95
|
+
!Number.isFinite(item.elapsedMs) || item.elapsedMs < 0 || !Number.isSafeInteger(item.operations) ||
|
|
96
|
+
item.operations < 0 || (item.operations === 0 && (item.bytes > 0 || item.elapsedMs > 0)) ||
|
|
97
|
+
(item.operations > 0 && (item.bytes === 0 || item.elapsedMs === 0)) || !Array.isArray(item.samples) ||
|
|
98
|
+
!item.samples.every(sample => Number.isSafeInteger(sample?.bytes) && sample.bytes > 0 &&
|
|
99
|
+
Number.isFinite(sample.elapsedMs) && sample.elapsedMs > 0 &&
|
|
100
|
+
Number.isFinite(sample.ageMs) && sample.ageMs >= 0 &&
|
|
101
|
+
(sample.parseElapsedMs === undefined || sample.parseElapsedMs === null ||
|
|
102
|
+
Number.isFinite(sample.parseElapsedMs) && sample.parseElapsedMs >= 0) &&
|
|
103
|
+
(sample.bufferingElapsedMs === undefined || sample.bufferingElapsedMs === null ||
|
|
104
|
+
Number.isFinite(sample.bufferingElapsedMs) && sample.bufferingElapsedMs >= 0) &&
|
|
105
|
+
(sample.bufferingAgeMs === undefined || sample.bufferingAgeMs === null ||
|
|
106
|
+
Number.isFinite(sample.bufferingAgeMs) && sample.bufferingAgeMs >= 0) &&
|
|
107
|
+
(sample.eventAgesMs === undefined || sample.eventAgesMs && typeof sample.eventAgesMs === "object" &&
|
|
108
|
+
!Array.isArray(sample.eventAgesMs) && Object.values(sample.eventAgesMs).every(age =>
|
|
109
|
+
age === null || Number.isFinite(age) && age >= 0)) &&
|
|
110
|
+
(sample.fragmentId === null || typeof sample.fragmentId === "string" && sample.fragmentId.length <= 128) &&
|
|
111
|
+
(sample.fragmentSequence === undefined || sample.fragmentSequence === null ||
|
|
112
|
+
Number.isSafeInteger(sample.fragmentSequence) && sample.fragmentSequence >= 0) &&
|
|
113
|
+
(sample.partIndex === undefined || sample.partIndex === null ||
|
|
114
|
+
Number.isSafeInteger(sample.partIndex) && sample.partIndex >= 0) &&
|
|
115
|
+
(sample.fragmentStartSeconds === null || Number.isFinite(sample.fragmentStartSeconds)))) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
snapshot[track][kind] = { bytes: item.bytes, elapsedMs: item.elapsedMs, operations: item.operations,
|
|
119
|
+
samples: item.samples.map(({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence, partIndex, fragmentStartSeconds,
|
|
120
|
+
parseElapsedMs, bufferingElapsedMs, bufferingAgeMs, eventAgesMs }) =>
|
|
121
|
+
({ bytes, elapsedMs, ageMs, fragmentId, fragmentSequence: fragmentSequence ?? null,
|
|
122
|
+
partIndex: partIndex ?? null, fragmentStartSeconds,
|
|
123
|
+
parseElapsedMs: parseElapsedMs ?? null, bufferingElapsedMs: bufferingElapsedMs ?? null,
|
|
124
|
+
bufferingAgeMs: bufferingAgeMs ?? null, eventAgesMs: fragmentEventAges(eventAgesMs) })) };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return snapshot;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function appendServiceIsMonotonic(next, previous) {
|
|
131
|
+
return APPEND_TRACKS.every(track => APPEND_KINDS.every(kind =>
|
|
132
|
+
next[track][kind].bytes >= previous[track][kind].bytes &&
|
|
133
|
+
next[track][kind].elapsedMs >= previous[track][kind].elapsedMs &&
|
|
134
|
+
next[track][kind].operations >= previous[track][kind].operations));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fragmentServiceIsMonotonic(next, previous) {
|
|
138
|
+
return APPEND_TRACKS.every(track => APPEND_KINDS.every(kind =>
|
|
139
|
+
next[track][kind].bytes >= previous[track][kind].bytes &&
|
|
140
|
+
next[track][kind].elapsedMs >= previous[track][kind].elapsedMs &&
|
|
141
|
+
next[track][kind].operations >= previous[track][kind].operations));
|
|
142
|
+
}
|
|
143
|
+
|
|
46
144
|
/**
|
|
47
145
|
* How long a generation this viewer has left behind goes on accepting NEW
|
|
48
146
|
* requests.
|
|
@@ -373,6 +471,11 @@ export class Viewer {
|
|
|
373
471
|
* report still stands: this is a statement about a viewer, not about a link.
|
|
374
472
|
* @param {number} [report.linkSampleAgeMs] - How old the newest raw link
|
|
375
473
|
* sample was when this report was made.
|
|
474
|
+
* @param {string} [report.appendSessionId] - Which output the page measured appends for.
|
|
475
|
+
* @param {string} [report.appendEpoch] - Counter identity; a new page reporter starts a new epoch.
|
|
476
|
+
* @param {number} [report.appendEpochStartedAt] - Browser epoch start clock used to reject delayed reports from an older reporter.
|
|
477
|
+
* @param {object} [report.appendService] - Cumulative append totals and recent per-operation byte/time samples.
|
|
478
|
+
* @param {object} [report.fragmentService] - Cumulative fragment parse totals and recent per-fragment samples.
|
|
376
479
|
* @param {number} report.bufferedAheadSec
|
|
377
480
|
* @param {number} [report.bufferLimitSeconds] - The largest forward buffer
|
|
378
481
|
* this browser accepted for the current player.
|
|
@@ -399,6 +502,12 @@ export class Viewer {
|
|
|
399
502
|
linkSampleMbps = null,
|
|
400
503
|
linkSampleAt = null,
|
|
401
504
|
linkSampleAgeMs = null,
|
|
505
|
+
appendSessionId = null,
|
|
506
|
+
appendEpoch = null,
|
|
507
|
+
appendEpochStartedAt = null,
|
|
508
|
+
appendSnapshotAt = null,
|
|
509
|
+
appendService: appendServiceReading = null,
|
|
510
|
+
fragmentService: fragmentServiceReading = null,
|
|
402
511
|
bufferedAheadSec,
|
|
403
512
|
bufferedRanges,
|
|
404
513
|
timestampOffsets,
|
|
@@ -439,6 +548,20 @@ export class Viewer {
|
|
|
439
548
|
const sampleMbps = Number(linkSampleMbps);
|
|
440
549
|
const sampleAt = Number(linkSampleAt);
|
|
441
550
|
const previous = this.netReport;
|
|
551
|
+
const incomingAppendService = typeof appendEpoch === "string" && appendEpoch.length > 0
|
|
552
|
+
? appendServiceSnapshot(appendServiceReading) : null;
|
|
553
|
+
const incomingFragmentService = typeof appendEpoch === "string" && appendEpoch.length > 0
|
|
554
|
+
? fragmentServiceSnapshot(fragmentServiceReading) : null;
|
|
555
|
+
const sameAppendEpoch = appendSessionId === previous?.appendSessionId && appendEpoch === previous?.appendEpoch;
|
|
556
|
+
const validAppendEpochTime = Number.isFinite(appendEpochStartedAt) && appendEpochStartedAt > 0;
|
|
557
|
+
const validAppendSnapshotTime = Number.isFinite(appendSnapshotAt) && appendSnapshotAt >= appendEpochStartedAt;
|
|
558
|
+
const useBrowserService = !stale && typeof appendSessionId === "string" && appendSessionId.length > 0 &&
|
|
559
|
+
incomingAppendService && incomingFragmentService &&
|
|
560
|
+
validAppendEpochTime && validAppendSnapshotTime && (sameAppendEpoch
|
|
561
|
+
? appendEpochStartedAt === previous?.appendEpochStartedAt && appendSnapshotAt > (previous?.appendSnapshotAt ?? 0) &&
|
|
562
|
+
(!previous?.appendService || appendServiceIsMonotonic(incomingAppendService, previous.appendService)) &&
|
|
563
|
+
(!previous?.fragmentService || fragmentServiceIsMonotonic(incomingFragmentService, previous.fragmentService))
|
|
564
|
+
: appendEpochStartedAt > (previous?.appendEpochStartedAt ?? 0));
|
|
442
565
|
const hasPlaybackSample = Number.isFinite(sampleMbps) && sampleMbps > 0 &&
|
|
443
566
|
Number.isFinite(sampleAt) && sampleAt > 0;
|
|
444
567
|
const newerPlaybackSample = hasPlaybackSample && sampleAt > (this.netReport?.linkSampleAt ?? 0);
|
|
@@ -452,6 +575,13 @@ export class Viewer {
|
|
|
452
575
|
? now - linkSampleAgeMs
|
|
453
576
|
: now
|
|
454
577
|
: this.netReport?.linkSampleMeasuredAt ?? null,
|
|
578
|
+
appendSessionId: useBrowserService ? appendSessionId : previous?.appendSessionId ?? null,
|
|
579
|
+
appendEpoch: useBrowserService ? appendEpoch : previous?.appendEpoch ?? null,
|
|
580
|
+
appendEpochStartedAt: useBrowserService ? appendEpochStartedAt : previous?.appendEpochStartedAt ?? null,
|
|
581
|
+
appendSnapshotAt: useBrowserService ? appendSnapshotAt : previous?.appendSnapshotAt ?? null,
|
|
582
|
+
appendService: useBrowserService ? incomingAppendService : previous?.appendService ?? null,
|
|
583
|
+
fragmentService: useBrowserService ? incomingFragmentService : previous?.fragmentService ?? null,
|
|
584
|
+
appendServiceReceivedAt: useBrowserService ? now : previous?.appendServiceReceivedAt ?? null,
|
|
455
585
|
bufferedAheadSec: held,
|
|
456
586
|
timestampOffsets: Object.fromEntries(["video", "audio"].filter((key) =>
|
|
457
587
|
Number.isFinite(timestampOffsets?.[key])).map((key) => [key, timestampOffsets[key]])),
|
|
@@ -42,6 +42,46 @@ import {
|
|
|
42
42
|
// timestamps can extend slightly beyond its nominal playlist boundary.
|
|
43
43
|
export const PLAYER_SEEK_TOLERANCE_SECONDS = 0.25;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Choose a point for a supported completion-time distribution under absolute
|
|
47
|
+
* relative error. For positive completions, minimizing E[|x - T| / T] is a
|
|
48
|
+
* weighted-median problem with mass p/T. A distribution mixing immediate and
|
|
49
|
+
* positive completion has no point under this loss definition.
|
|
50
|
+
*
|
|
51
|
+
* This summarizes an already-supported probability distribution; it does not
|
|
52
|
+
* fit one from completed whole waits or supply missing service observations.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<{ completionSeconds: number, probability: number }>} support
|
|
55
|
+
* @returns {{ pointSeconds: number | null, immediateProbability: number, supportCount: number } | null}
|
|
56
|
+
*/
|
|
57
|
+
export function completionPointUnderRelativeLoss(support) {
|
|
58
|
+
if (!Array.isArray(support) || support.length === 0 || support.some(sample =>
|
|
59
|
+
!Number.isFinite(sample?.completionSeconds) || sample.completionSeconds < 0 ||
|
|
60
|
+
!Number.isFinite(sample?.probability) || sample.probability < 0)) return null;
|
|
61
|
+
const positiveMass = support.filter(sample => sample.probability > 0);
|
|
62
|
+
const totalProbability = positiveMass.reduce((sum, sample) => sum + sample.probability, 0);
|
|
63
|
+
if (!(totalProbability > 0) || !Number.isFinite(totalProbability)) return null;
|
|
64
|
+
const immediateProbability = positiveMass.filter(sample => sample.completionSeconds === 0)
|
|
65
|
+
.reduce((sum, sample) => sum + sample.probability, 0) / totalProbability;
|
|
66
|
+
const positive = positiveMass.filter(sample => sample.completionSeconds > 0);
|
|
67
|
+
if (positive.length === 0) return { pointSeconds: 0, immediateProbability: 1, supportCount: positiveMass.length };
|
|
68
|
+
if (immediateProbability > 0) {
|
|
69
|
+
return { pointSeconds: null, immediateProbability, supportCount: positiveMass.length };
|
|
70
|
+
}
|
|
71
|
+
const weighted = positive.map(sample => ({ seconds: sample.completionSeconds,
|
|
72
|
+
weight: sample.probability / sample.completionSeconds })).sort((left, right) => left.seconds - right.seconds);
|
|
73
|
+
const totalWeight = weighted.reduce((sum, sample) => sum + sample.weight, 0);
|
|
74
|
+
if (!(totalWeight > 0) || !Number.isFinite(totalWeight)) return null;
|
|
75
|
+
let cumulativeWeight = 0;
|
|
76
|
+
for (const sample of weighted) {
|
|
77
|
+
cumulativeWeight += sample.weight;
|
|
78
|
+
if (cumulativeWeight * 2 >= totalWeight) {
|
|
79
|
+
return { pointSeconds: sample.seconds, immediateProbability: 0, supportCount: positiveMass.length };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/**
|
|
46
86
|
* @param {number} segmentEndSeconds
|
|
47
87
|
* @param {number} positionSeconds
|
|
@@ -191,6 +231,7 @@ export class RateTrend {
|
|
|
191
231
|
* segmentCount?: number,
|
|
192
232
|
* bitsPerMediaSecond: number, readings: Array<{ at: number, value: number }>,
|
|
193
233
|
* clockOffsetSeconds?: number, clientRanges?: Array<{ start: number, end: number }>,
|
|
234
|
+
* appendService?: object, fragmentService?: object,
|
|
194
235
|
* segments: Array<{ index: number, startSeconds: number, endSeconds: number,
|
|
195
236
|
* mediaRanges?: { timescale: bigint | null, ranges: Array<{ start: bigint, end: bigint, frame: bigint }> } }>,
|
|
196
237
|
* readySegmentIndices: number[], segmentSizesBytes: Map<number, number> | object }>} input.tracks
|
|
@@ -300,6 +341,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
300
341
|
: Math.max(0, ...track.segments.map(segment => segment.index + 1)),
|
|
301
342
|
clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
|
|
302
343
|
}));
|
|
344
|
+
const missingFirstGeometry = trackState.slice(0, requiredTracks).some(({ segments }) => {
|
|
345
|
+
const first = segments.find(segment => segment.endSeconds > position);
|
|
346
|
+
return first && first.ranges.length === 0;
|
|
347
|
+
});
|
|
348
|
+
if (missingFirstGeometry) {
|
|
349
|
+
return result(false, null, buffered, reserve, null, "first-required-media-ranges-unavailable", 0);
|
|
350
|
+
}
|
|
303
351
|
for (const state of trackState) {
|
|
304
352
|
state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
|
|
305
353
|
? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
|
|
@@ -707,16 +755,68 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
707
755
|
const first = state.segments.find(segment => segment.endSeconds > position);
|
|
708
756
|
if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
|
|
709
757
|
sourceArrivalSeconds: 0, runAdmissionSeconds: null, runOpenSeconds: 0, productionStartSeconds: 0,
|
|
710
|
-
productionCompleteSeconds: 0, deliveryCompleteSeconds: 0,
|
|
758
|
+
productionCompleteSeconds: 0, deliveryCompleteSeconds: 0, browserLoadToParseSeconds: 0,
|
|
759
|
+
appendRequestToAckSeconds: 0, appendSeconds: 0, completionSeconds: 0 };
|
|
760
|
+
if (!Array.isArray(first.ranges) || first.ranges.length === 0) return {
|
|
761
|
+
track: state.track.id ?? trackIndex,
|
|
762
|
+
segment: first.index,
|
|
763
|
+
alreadyBuffered: null,
|
|
764
|
+
mediaRangesReason: "first-required-media-ranges-unavailable",
|
|
765
|
+
sourceArrivalSeconds: null,
|
|
766
|
+
runAdmissionSeconds: null,
|
|
767
|
+
runOpenSeconds: null,
|
|
768
|
+
productionStartSeconds: null,
|
|
769
|
+
productionCompleteSeconds: null,
|
|
770
|
+
deliveryCompleteSeconds: null,
|
|
771
|
+
browserLoadToParseSeconds: null,
|
|
772
|
+
browserLoadToParseReason: "fragment-load-to-parse-distribution-unavailable",
|
|
773
|
+
appendRequestToAckSeconds: null,
|
|
774
|
+
appendRequestToAckReason: "append-request-to-ack-distribution-unavailable",
|
|
775
|
+
appendSeconds: null,
|
|
776
|
+
appendReason: "append-request-to-ack-distribution-unavailable",
|
|
777
|
+
completionSeconds: null
|
|
778
|
+
};
|
|
711
779
|
if (first.ranges.every(range => contains(state.clientRanges, range.start, range.end))) {
|
|
712
780
|
return { track: state.track.id ?? trackIndex, segment: first.index, alreadyBuffered: true,
|
|
713
781
|
sourceArrivalSeconds: 0, runAdmissionSeconds: null, runOpenSeconds: 0, productionStartSeconds: 0,
|
|
714
|
-
productionCompleteSeconds: 0, deliveryCompleteSeconds: 0,
|
|
782
|
+
productionCompleteSeconds: 0, deliveryCompleteSeconds: 0, browserLoadToParseSeconds: 0,
|
|
783
|
+
appendRequestToAckSeconds: 0, appendSeconds: 0, completionSeconds: 0 };
|
|
715
784
|
}
|
|
716
785
|
const completion = completions.find(item => item.trackIndex === trackIndex && item.segment.index === first.index);
|
|
786
|
+
// HLS playlists for these generated outputs use MEDIA-SEQUENCE:0, so a
|
|
787
|
+
// complete media fragment's HLS sequence number is the output segment
|
|
788
|
+
// index. Keep matched browser observations as evidence only: their recent
|
|
789
|
+
// completed-work spans are not a future-service distribution.
|
|
790
|
+
const samplesForFirstSegment = (kind) => (service) => {
|
|
791
|
+
const samples = service?.[kind]?.samples;
|
|
792
|
+
return Array.isArray(samples) ? samples.filter(sample => sample.fragmentSequence === first.index &&
|
|
793
|
+
sample.partIndex === null) : [];
|
|
794
|
+
};
|
|
795
|
+
const observedBrowserWork = {
|
|
796
|
+
evidenceOnly: true,
|
|
797
|
+
fragmentLoadToParse: samplesForFirstSegment("media")(state.track.fragmentService).map(sample => ({
|
|
798
|
+
bytes: sample.bytes,
|
|
799
|
+
elapsedMs: sample.elapsedMs,
|
|
800
|
+
parseElapsedMs: sample.parseElapsedMs ?? null,
|
|
801
|
+
bufferingElapsedMs: sample.bufferingElapsedMs ?? null,
|
|
802
|
+
ageMs: sample.ageMs,
|
|
803
|
+
bufferingAgeMs: sample.bufferingAgeMs ?? null,
|
|
804
|
+
eventAgesMs: sample.eventAgesMs ?? null
|
|
805
|
+
})),
|
|
806
|
+
appendRequestToAck: samplesForFirstSegment("media")(state.track.appendService).map(sample => ({
|
|
807
|
+
bytes: sample.bytes,
|
|
808
|
+
elapsedMs: sample.elapsedMs,
|
|
809
|
+
ageMs: sample.ageMs
|
|
810
|
+
}))
|
|
811
|
+
};
|
|
812
|
+
const observedBrowserWorkField = observedBrowserWork.fragmentLoadToParse.length > 0 ||
|
|
813
|
+
observedBrowserWork.appendRequestToAck.length > 0 ? { observedBrowserWork } : {};
|
|
717
814
|
if (!completion) return { track: state.track.id ?? trackIndex, segment: first.index, alreadyBuffered: false,
|
|
718
815
|
sourceArrivalSeconds: null, runAdmissionSeconds: null, runOpenSeconds: null, productionStartSeconds: null,
|
|
719
|
-
productionCompleteSeconds: null, deliveryCompleteSeconds: null,
|
|
816
|
+
productionCompleteSeconds: null, deliveryCompleteSeconds: null, browserLoadToParseSeconds: null,
|
|
817
|
+
browserLoadToParseReason: "fragment-load-to-parse-distribution-unavailable",
|
|
818
|
+
appendRequestToAckSeconds: null, appendRequestToAckReason: "append-request-to-ack-distribution-unavailable",
|
|
819
|
+
appendSeconds: null, completionSeconds: null, ...observedBrowserWorkField };
|
|
720
820
|
const production = productions.find(item => item.trackIndex === trackIndex && item.segment.index === first.index);
|
|
721
821
|
return { track: state.track.id ?? trackIndex, segment: first.index, alreadyBuffered: false,
|
|
722
822
|
sourceArrivalSeconds: production?.sourceArrivalAt ?? 0,
|
|
@@ -725,8 +825,14 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
725
825
|
productionStartSeconds: production?.productionStartAt ?? null,
|
|
726
826
|
productionCompleteSeconds: production?.at ?? null,
|
|
727
827
|
deliveryCompleteSeconds: completion.deliveryAt,
|
|
728
|
-
|
|
729
|
-
|
|
828
|
+
browserLoadToParseSeconds: null,
|
|
829
|
+
browserLoadToParseReason: "fragment-load-to-parse-distribution-unavailable",
|
|
830
|
+
appendRequestToAckSeconds: null,
|
|
831
|
+
appendRequestToAckReason: "append-request-to-ack-distribution-unavailable",
|
|
832
|
+
appendSeconds: null,
|
|
833
|
+
appendReason: "append-request-to-ack-distribution-unavailable",
|
|
834
|
+
completionSeconds: null,
|
|
835
|
+
...observedBrowserWorkField };
|
|
730
836
|
});
|
|
731
837
|
const firstRequiredTrackSeconds = firstRequiredStages.map(stage => stage.completionSeconds);
|
|
732
838
|
const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
|
|
@@ -735,16 +841,19 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
735
841
|
// THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
|
|
736
842
|
// source comes slower than the film plays means holding, at the start, what
|
|
737
843
|
// is already here and everything that arrives before it. Where that is more
|
|
738
|
-
// than
|
|
739
|
-
// given up before the viewer reached it,
|
|
844
|
+
// than the disk room left after other active playback stock is reserved,
|
|
845
|
+
// some of it would be given up before the viewer reached it, so no wait
|
|
846
|
+
// makes the start safe:
|
|
740
847
|
// Despicable Me 3 BDRemux at 1.4 MB/s asked for about 36 GB against a 29.5 GB
|
|
741
848
|
// share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
|
|
742
849
|
// the viewer keeps waiting rather than starting into stops.
|
|
743
850
|
const storageBytes = Number(input.sourceStorageBytes);
|
|
744
851
|
const tailParts = tailStock.filter(part => !part.skipped);
|
|
852
|
+
const cushionBytes = Number.isFinite(delay) && tailParts.length > 0
|
|
853
|
+
? tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
854
|
+
Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0)
|
|
855
|
+
: null;
|
|
745
856
|
if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailParts.length > 0) {
|
|
746
|
-
const cushionBytes = tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
747
|
-
Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0);
|
|
748
857
|
if (cushionBytes > storageBytes) {
|
|
749
858
|
return { ...result(false, null, buffered, reserve, null, "cushion-exceeds-storage", preparedSegments),
|
|
750
859
|
cushionBytes, storageBytes, tail: tailStock };
|
|
@@ -756,6 +865,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
756
865
|
Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
|
|
757
866
|
Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
|
|
758
867
|
return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
|
|
868
|
+
...(cushionBytes === null ? {} : { cushionBytes }),
|
|
759
869
|
firstRequiredSegmentsSeconds,
|
|
760
870
|
firstRequiredStages,
|
|
761
871
|
// What the rest of the film weighs per source, at what rate it comes and
|