@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
|
@@ -35,12 +35,6 @@ export function sourceFileIndexesForOutput(output) {
|
|
|
35
35
|
* @param {Array<{ sourceId: string, ranges: object[] }> | undefined} right
|
|
36
36
|
* @returns {boolean}
|
|
37
37
|
*/
|
|
38
|
-
function sameInputs(left, right) {
|
|
39
|
-
if (left === right) return true;
|
|
40
|
-
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
41
|
-
return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
38
|
function browserServiceSummary(value) {
|
|
45
39
|
if (!value || typeof value !== "object") return null;
|
|
46
40
|
return Object.fromEntries(["init", "media"].filter(kind => value[kind]).map(kind => [kind, value[kind]]).map(([kind, service]) => [kind, {
|
|
@@ -93,6 +87,49 @@ function forecastInputIdentity(state) {
|
|
|
93
87
|
return state && JSON.stringify([state.sourceId, state.trackId, state.segmentIndex, state.phase]);
|
|
94
88
|
}
|
|
95
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
|
+
|
|
96
133
|
function observeForecastInput(observeWait, sourceKey, state, event) {
|
|
97
134
|
if (!state) return;
|
|
98
135
|
observeWait?.(sourceKey, "process", {
|
|
@@ -119,53 +156,43 @@ function segmentAddressOf(output, fileName) {
|
|
|
119
156
|
export class ViewerRequests {
|
|
120
157
|
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
121
158
|
#host;
|
|
122
|
-
/** @type {WeakMap<object, Map<string, object>>} */
|
|
123
|
-
#rateStateBySession = new WeakMap();
|
|
124
|
-
/** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, observedWaits: Set<number> }>} */
|
|
125
|
-
#waitObservations = new Map();
|
|
126
159
|
/**
|
|
127
|
-
*
|
|
128
|
-
* their time on the main thread, and for each track whether the times of its
|
|
129
|
-
* pieces were kept from the last poll or why they were worked out again. In
|
|
130
|
-
* r37 the forecast took 19.2 s of Home Assistant's main thread in 15 minutes
|
|
131
|
-
* and the times were mostly worked out again (torrent-tv/meta#178).
|
|
160
|
+
* @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `planEncodersSoon`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `placeViewerOn`, `chooseAudioTrack`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
|
|
132
161
|
*/
|
|
133
|
-
|
|
162
|
+
constructor(host) {
|
|
163
|
+
this.#host = host;
|
|
164
|
+
}
|
|
134
165
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
costs.polls += 1; costs.ms += ms; costs.maxMs = Math.max(costs.maxMs, ms);
|
|
138
|
-
for (const reason of reasons) costs.times.set(reason, (costs.times.get(reason) ?? 0) + 1);
|
|
139
|
-
const now = Date.now();
|
|
140
|
-
if (now - costs.since < 60_000) return;
|
|
141
|
-
logger.info(`start forecasts in ${((now - costs.since) / 1000).toFixed(1)}s: ${costs.polls}, ` +
|
|
142
|
-
`${Math.round(costs.ms)}ms in all, ${(costs.ms / costs.polls).toFixed(1)}ms each, longest ${Math.round(costs.maxMs)}ms; ` +
|
|
143
|
-
`times of a track's pieces: ${[...costs.times].map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
|
|
144
|
-
Object.assign(costs, { since: now, polls: 0, ms: 0, maxMs: 0, times: new Map() });
|
|
166
|
+
viewerDeparted(viewer) {
|
|
167
|
+
if (viewer) this.#host.viewerPlaybackState.forgetViewer(viewer);
|
|
145
168
|
}
|
|
146
169
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
* they stand (`playback-readiness.js` `timescaleOfPieces`).
|
|
151
|
-
*
|
|
152
|
-
* @type {WeakMap<object, { pieces: object[], geometry: object }>}
|
|
153
|
-
*/
|
|
154
|
-
#forecastPieces = new WeakMap();
|
|
170
|
+
viewerTokenOf(consumerId) {
|
|
171
|
+
return consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
172
|
+
}
|
|
155
173
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
174
|
+
outputViewerDeparted(viewer, output) {
|
|
175
|
+
if (!viewer || !output) return;
|
|
176
|
+
if (this.#host.viewers.get(viewer.id) === viewer) {
|
|
177
|
+
this.#host.viewerPlaybackState.forgetViewerOutput(viewer, output);
|
|
178
|
+
} else {
|
|
179
|
+
this.#host.viewerPlaybackState.forgetViewer(viewer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
outputDisposed(output) {
|
|
184
|
+
this.#host.viewerPlaybackState.forgetOutput(output);
|
|
185
|
+
this.#host.outputForecastGeometry.forget(output);
|
|
161
186
|
}
|
|
162
187
|
|
|
163
188
|
/** Record the facts known while listing a source, without producing an ETA. */
|
|
164
|
-
noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs
|
|
189
|
+
noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
|
|
190
|
+
viewerToken }) {
|
|
165
191
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
|
|
166
192
|
!Number.isSafeInteger(fileIndex) || fileIndex < 0) return;
|
|
167
193
|
const waitId = `${sourceKey}:${fileIndex}:${generation}`;
|
|
168
|
-
const
|
|
194
|
+
const viewer = this.#currentViewerToken(consumerId, viewerToken);
|
|
195
|
+
if (!viewer) return;
|
|
169
196
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
170
197
|
const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
|
|
171
198
|
const features = {
|
|
@@ -179,10 +206,8 @@ export class ViewerRequests {
|
|
|
179
206
|
headerRemaining: null,
|
|
180
207
|
peers: null
|
|
181
208
|
};
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
this.#recordWaitSnapshot(context);
|
|
185
|
-
this.#pruneWaitObservations();
|
|
209
|
+
this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
|
|
210
|
+
this.#host.viewerPlaybackState.pruneWaits();
|
|
186
211
|
}
|
|
187
212
|
|
|
188
213
|
/**
|
|
@@ -194,10 +219,11 @@ export class ViewerRequests {
|
|
|
194
219
|
* @returns {object | null}
|
|
195
220
|
*/
|
|
196
221
|
noteWaitSourceSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
|
|
197
|
-
stats = {}, linkMbps, linkSampleAgeMs, processing = {} }) {
|
|
222
|
+
stats = {}, linkMbps, linkSampleAgeMs, processing = {}, viewerToken }) {
|
|
198
223
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return;
|
|
199
224
|
const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
|
|
200
|
-
const
|
|
225
|
+
const viewer = this.#currentViewerToken(consumerId, viewerToken);
|
|
226
|
+
if (!viewer) return;
|
|
201
227
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
202
228
|
const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
|
|
203
229
|
const features = {
|
|
@@ -218,10 +244,8 @@ export class ViewerRequests {
|
|
|
218
244
|
videoSpeedX: processing.videoSpeedX ?? null,
|
|
219
245
|
audioSpeedX: processing.audioSpeedX ?? null
|
|
220
246
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
this.#recordWaitSnapshot(context);
|
|
224
|
-
this.#pruneWaitObservations();
|
|
247
|
+
this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
|
|
248
|
+
this.#host.viewerPlaybackState.pruneWaits();
|
|
225
249
|
}
|
|
226
250
|
|
|
227
251
|
/**
|
|
@@ -235,49 +259,14 @@ export class ViewerRequests {
|
|
|
235
259
|
* @returns {void}
|
|
236
260
|
*/
|
|
237
261
|
noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs, stages = null }) {
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const initialFeatures = observation.snapshots[0];
|
|
243
|
-
if (initialFeatures) {
|
|
244
|
-
this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
|
|
245
|
-
featureSnapshots: observation.snapshots.slice(1), stages });
|
|
246
|
-
}
|
|
247
|
-
observation.observedWaits.add(wait);
|
|
248
|
-
observation.updatedAt = Date.now();
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current }) {
|
|
252
|
-
const sameWait = current?.waitId === waitId;
|
|
253
|
-
const context = sameWait ? current : {
|
|
254
|
-
consumerId,
|
|
255
|
-
generation,
|
|
256
|
-
sourceKey,
|
|
257
|
-
fileIndex,
|
|
258
|
-
waitId,
|
|
259
|
-
snapshots: [],
|
|
260
|
-
observedWaits: new Set()
|
|
261
|
-
};
|
|
262
|
-
const measuredAt = performance.now();
|
|
263
|
-
const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
|
|
264
|
-
? Math.max(0, measuredAt - context.featuresMeasuredAt)
|
|
265
|
-
: 0;
|
|
266
|
-
const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
|
|
267
|
-
Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
|
|
268
|
-
featuresMeasuredAt: measuredAt, updatedAt: Date.now() });
|
|
269
|
-
this.#waitObservations.set(`${consumerId}:${waitId}`, context);
|
|
270
|
-
return context;
|
|
262
|
+
const viewer = this.#host.viewers.get(consumerId);
|
|
263
|
+
if (!viewer) return;
|
|
264
|
+
this.#host.viewerPlaybackState.noteFirstFrame({ viewer, generation, waitId, wait, elapsedMs, stages,
|
|
265
|
+
record: observation => this.#host.noteFirstFrame?.({ ...observation, stages }) });
|
|
271
266
|
}
|
|
272
267
|
|
|
273
|
-
#
|
|
274
|
-
|
|
275
|
-
const previous = context.snapshots.at(-1);
|
|
276
|
-
if (previous && JSON.stringify(previous) === JSON.stringify(features)) return false;
|
|
277
|
-
const snapshot = { ...features };
|
|
278
|
-
if (context.snapshots.length < 2) context.snapshots.push(snapshot);
|
|
279
|
-
else context.snapshots[1] = snapshot;
|
|
280
|
-
return true;
|
|
268
|
+
#currentViewerToken(consumerId, viewerToken) {
|
|
269
|
+
return viewerToken && this.#host.viewers.get(consumerId) === viewerToken ? viewerToken : null;
|
|
281
270
|
}
|
|
282
271
|
|
|
283
272
|
#preparedOutputBytes(sourceKey, fileIndex) {
|
|
@@ -307,14 +296,6 @@ export class ViewerRequests {
|
|
|
307
296
|
return bytes;
|
|
308
297
|
}
|
|
309
298
|
|
|
310
|
-
#pruneWaitObservations() {
|
|
311
|
-
const cutoff = Date.now() - 30 * 60_000;
|
|
312
|
-
for (const [key, value] of this.#waitObservations) {
|
|
313
|
-
if (value.updatedAt < cutoff) this.#waitObservations.delete(key);
|
|
314
|
-
}
|
|
315
|
-
while (this.#waitObservations.size > 1000) this.#waitObservations.delete(this.#waitObservations.keys().next().value);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
299
|
/**
|
|
319
300
|
* Open an output of a file for this viewer and put them on it.
|
|
320
301
|
*
|
|
@@ -548,10 +529,10 @@ export class ViewerRequests {
|
|
|
548
529
|
if (!Number.isSafeInteger(generation) || !file || typeof file.sourceKey !== "string" ||
|
|
549
530
|
!Number.isSafeInteger(file.fileIndex) || file.fileIndex < 0) return;
|
|
550
531
|
const waitId = `${file.sourceKey}:${file.fileIndex}:${generation}`;
|
|
551
|
-
const key = `${consumerId}:${waitId}`;
|
|
552
532
|
const fileName = file.relativePath ?? file.name ?? "";
|
|
553
533
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
554
534
|
const viewer = this.#host.viewers.get(consumerId);
|
|
535
|
+
if (!viewer) return;
|
|
555
536
|
const link = linkMeasurementOf(viewer);
|
|
556
537
|
const features = {
|
|
557
538
|
extension,
|
|
@@ -570,10 +551,9 @@ export class ViewerRequests {
|
|
|
570
551
|
};
|
|
571
552
|
// A seek is a new observation group even when a caller repeats the same
|
|
572
553
|
// generation; its source facts must not join observations from the prior wait.
|
|
573
|
-
this.#
|
|
574
|
-
|
|
575
|
-
fileIndex: file.fileIndex, waitId, features
|
|
576
|
-
this.#recordWaitSnapshot(context);
|
|
554
|
+
this.#host.viewerPlaybackState.deleteWait(viewer, waitId);
|
|
555
|
+
this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey: file.sourceKey,
|
|
556
|
+
fileIndex: file.fileIndex, waitId, features });
|
|
577
557
|
}
|
|
578
558
|
|
|
579
559
|
/**
|
|
@@ -675,7 +655,7 @@ export class ViewerRequests {
|
|
|
675
655
|
return this.#host.viewerSecondsOn(session, consumerId);
|
|
676
656
|
}
|
|
677
657
|
|
|
678
|
-
async getSessionProgress(sessionId, consumerId = "") {
|
|
658
|
+
async getSessionProgress(sessionId, consumerId = "", statedGeneration = Number.NaN) {
|
|
679
659
|
if (!isOutputName(sessionId)) {
|
|
680
660
|
return null;
|
|
681
661
|
}
|
|
@@ -690,6 +670,14 @@ export class ViewerRequests {
|
|
|
690
670
|
// without this the base session would idle out from under its own variants.
|
|
691
671
|
const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
|
|
692
672
|
this.#host.outputs.touch(session);
|
|
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;
|
|
680
|
+
const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
|
|
693
681
|
const audio = this.#host.renditions.servesAudioSeparately(session)
|
|
694
682
|
? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
|
|
695
683
|
const failed = [session, audio].find(output => output && this.#host.encodeRuns.wireStateOf(output) === "failed");
|
|
@@ -709,8 +697,13 @@ export class ViewerRequests {
|
|
|
709
697
|
// Observed output bitrate (Mbit/s) from recently completed segments. The
|
|
710
698
|
// proxy uses it for the viewer-link budget and the readiness forecast.
|
|
711
699
|
const outputMbps = await this.#host.quality.observedStreamMbps(session);
|
|
700
|
+
if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
|
|
701
|
+
if (!stillOwnsProgress()) return null;
|
|
712
702
|
const progress = this.#host.encodeRuns.progressOf(session);
|
|
713
|
-
const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps
|
|
703
|
+
const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
|
|
704
|
+
viewer, stillOwnsProgress);
|
|
705
|
+
if (!requestGenerationIsCurrent()) return requestGenerationIsStated ? { superseded: true } : null;
|
|
706
|
+
if (!stillOwnsProgress()) return null;
|
|
714
707
|
return {
|
|
715
708
|
// The id the caller asked about, not the variant it was answered from —
|
|
716
709
|
// the browser tracks its sessions by the id it was given.
|
|
@@ -795,7 +788,16 @@ export class ViewerRequests {
|
|
|
795
788
|
};
|
|
796
789
|
}
|
|
797
790
|
|
|
798
|
-
|
|
791
|
+
#stillOwnsProgress(named, session, consumerId, viewer) {
|
|
792
|
+
if (this.#host.outputs.get(named.id) !== named || this.#host.outputs.get(session.id) !== session) return false;
|
|
793
|
+
if (!viewer) return !consumerId || !this.#host.viewers.get(consumerId);
|
|
794
|
+
return this.#host.viewers.get(consumerId) === viewer &&
|
|
795
|
+
(!(viewer.outputs instanceof Set) || viewer.outputs.has(named.id)) &&
|
|
796
|
+
this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs }) === session;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async #playbackReadinessFor(session, consumerId, progress, outputMbps, viewer, stillOwnsProgress) {
|
|
800
|
+
if (!stillOwnsProgress()) return null;
|
|
799
801
|
const waitStartedAt = performance.now();
|
|
800
802
|
const waitFields = { process: "P14", sessionId: session.id, consumerId };
|
|
801
803
|
this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
|
|
@@ -806,13 +808,32 @@ export class ViewerRequests {
|
|
|
806
808
|
// held viewer's answer took 11.2 s while its forecast took 0.1-0.4 s and
|
|
807
809
|
// the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
|
|
808
810
|
const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
|
|
809
|
-
const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
810
811
|
const viewerReading = viewer?.linkReading() ?? null;
|
|
811
|
-
const
|
|
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;
|
|
818
|
+
const measurement = viewer
|
|
819
|
+
? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
|
|
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
|
+
}
|
|
812
827
|
const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
|
|
813
|
-
if (
|
|
814
|
-
|
|
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);
|
|
815
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;
|
|
816
837
|
|
|
817
838
|
const outputs = [session];
|
|
818
839
|
const requiresSeparateAudio = this.#host.renditions.servesAudioSeparately(session);
|
|
@@ -828,20 +849,24 @@ export class ViewerRequests {
|
|
|
828
849
|
let primarySourceStats = null;
|
|
829
850
|
const tracks = [];
|
|
830
851
|
for (const output of outputs) {
|
|
852
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
831
853
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
832
|
-
const
|
|
854
|
+
const trackRate = this.#trackRateReadings(output, now);
|
|
855
|
+
const trackRates = trackRate?.summary ?? null;
|
|
833
856
|
const trackName = output === session ? "video" : "audio";
|
|
834
857
|
// The page states the offset its player applied to this track, and the
|
|
835
858
|
// init it applied it to is the session's. Until both are known, the
|
|
836
859
|
// pieces stay on their own presentation timeline: there is nothing yet
|
|
837
860
|
// to place them by.
|
|
838
|
-
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;
|
|
839
864
|
const servedInit = Number.isFinite(playerOffset) ? this.#host.segmentStore.initOf(output.outputKey) : null;
|
|
840
865
|
const placedByPlayer = Boolean(servedInit?.length);
|
|
841
866
|
const sessionInit = placedByPlayer ? servedInit : null;
|
|
842
867
|
const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
|
|
843
|
-
const segments = this.#segmentsOf(output, sessionInit, inventory,
|
|
844
|
-
?
|
|
868
|
+
const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
|
|
869
|
+
Number.isFinite(reportedPosition) ? reportedPosition : this.#host.viewerSecondsOn(session, consumerId, now));
|
|
845
870
|
const sourceIndexes = [...output.spec.sourceFileIndexes];
|
|
846
871
|
if (sourceIndexes.length === 0) {
|
|
847
872
|
sourceIndexes.push(output.file.fileIndex);
|
|
@@ -861,30 +886,62 @@ export class ViewerRequests {
|
|
|
861
886
|
} catch {
|
|
862
887
|
stats = null;
|
|
863
888
|
}
|
|
889
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
864
890
|
if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
|
|
865
891
|
parts.statsMs += performance.now() - statsAt;
|
|
866
892
|
parts.workerForecastMs += Number(stats?.forecastMs) || 0;
|
|
893
|
+
const sourceCapture = sourceCaptureOf(stats, actionId, generation);
|
|
867
894
|
if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
|
|
868
895
|
const sharedForecast = sourceForecasts.get(output.file.sourceKey);
|
|
869
|
-
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,
|
|
870
902
|
ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
|
|
871
903
|
start: Math.max(0, range.start - stats.fileOffset),
|
|
872
904
|
end: Math.min(stats.fileLength, range.end - stats.fileOffset)
|
|
873
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;
|
|
874
928
|
sourceMeasurements.set(sourceId, {
|
|
875
929
|
id: sourceId,
|
|
876
930
|
serviceKey: output.file.sourceKey,
|
|
877
|
-
complete: stats?.fileAvailable === true,
|
|
878
|
-
residence: stats?.residence,
|
|
879
|
-
fileOffset: stats?.fileOffset,
|
|
880
|
-
fileLength: stats?.fileLength,
|
|
881
|
-
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 &&
|
|
882
936
|
Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
|
|
883
937
|
? stats.fileLength / session.file.durationSeconds : null,
|
|
884
|
-
pieceLength: stats?.pieceLength,
|
|
885
|
-
downloadRateReadings
|
|
886
|
-
tailRateReadings
|
|
887
|
-
downloadRateSummaries
|
|
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 },
|
|
888
945
|
downloadForecast,
|
|
889
946
|
});
|
|
890
947
|
}
|
|
@@ -893,35 +950,38 @@ export class ViewerRequests {
|
|
|
893
950
|
const observedMbps = output === session
|
|
894
951
|
? outputMbps
|
|
895
952
|
: await this.#host.quality.observedStreamMbps(output);
|
|
953
|
+
if (!stillOwnsProgress() || !actionIsCurrent() || this.#host.outputs.get(output.id) !== output) return null;
|
|
896
954
|
parts.qualityMs += performance.now() - qualityAt;
|
|
897
955
|
const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
|
|
956
|
+
const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
|
|
957
|
+
const encoderCaptureValid = encoderCapture.validity === "valid";
|
|
898
958
|
const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
|
|
899
959
|
const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
|
|
900
960
|
tracks.push({
|
|
901
961
|
id: output.outputKey,
|
|
902
962
|
clockOffsetSeconds: placedByPlayer ? playerOffset : 0,
|
|
903
|
-
clientRanges: viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
904
|
-
viewerReading?.bufferedRanges?.media,
|
|
963
|
+
clientRanges: bufferCaptureValid ? viewerReading?.bufferedRanges?.[output === session ? "video" : "audio"] ??
|
|
964
|
+
viewerReading?.bufferedRanges?.media : null,
|
|
905
965
|
// Browser-observed append and fragment-parse samples are retained for
|
|
906
966
|
// the full model's service schedule; they are not yet used as numeric
|
|
907
967
|
// predecessors in the partial continuity calculation.
|
|
908
|
-
appendService: viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
968
|
+
appendService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.appendService?.[trackName]
|
|
909
969
|
? { ...viewerReading.appendService[trackName], epoch: viewerReading.appendEpoch,
|
|
910
970
|
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
911
971
|
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
912
972
|
: null,
|
|
913
|
-
fragmentService: viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
973
|
+
fragmentService: bufferCaptureValid && viewerReading?.appendSessionId === session.id && viewerReading?.fragmentService?.[trackName]
|
|
914
974
|
? { ...viewerReading.fragmentService[trackName], epoch: viewerReading.appendEpoch,
|
|
915
975
|
epochStartedAt: viewerReading.appendEpochStartedAt, snapshotAt: viewerReading.appendSnapshotAt,
|
|
916
976
|
receivedAt: viewerReading.appendServiceReceivedAt }
|
|
917
977
|
: null,
|
|
918
978
|
sourceIds,
|
|
919
|
-
processedSeconds: trackProgress?.processedSeconds,
|
|
920
|
-
startupRemainingSeconds: processingStarted ? 0 : Math.max(0,
|
|
979
|
+
processedSeconds: encoderCaptureValid ? trackProgress?.processedSeconds : null,
|
|
980
|
+
startupRemainingSeconds: !encoderCaptureValid ? null : processingStarted ? 0 : Math.max(0,
|
|
921
981
|
(this.#host.processingStartupSecondsOf?.(output) ?? 0) - Math.max(0, (now - startedAt) / 1000)),
|
|
922
|
-
processingRanges: liveRuns.map(({ progress }) => ({
|
|
982
|
+
processingRanges: encoderCaptureValid ? liveRuns.map(({ progress }) => ({
|
|
923
983
|
start: progress.startPositionSeconds, end: progress.processedSeconds
|
|
924
|
-
})),
|
|
984
|
+
})) : [],
|
|
925
985
|
// What a run's start costs this output, and the stretches its live
|
|
926
986
|
// runs were given: the forecast starts a run wherever production meets
|
|
927
987
|
// bytes that were not held when the run before was admitted.
|
|
@@ -934,16 +994,19 @@ export class ViewerRequests {
|
|
|
934
994
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
935
995
|
durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
|
|
936
996
|
segmentCount: output.timeline?.segmentCount ?? segments.length,
|
|
937
|
-
readings: trackRates,
|
|
997
|
+
readings: encoderCaptureValid ? trackRates : [],
|
|
998
|
+
capture: encoderCapture,
|
|
938
999
|
segments,
|
|
939
|
-
geometry: this.#
|
|
1000
|
+
geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
|
|
940
1001
|
readySegmentIndices: [...inventory.keys()],
|
|
941
1002
|
segmentSizesBytes: inventory
|
|
942
1003
|
});
|
|
943
1004
|
}
|
|
944
1005
|
|
|
945
|
-
|
|
946
|
-
|
|
1006
|
+
if (!stillOwnsProgress() || !actionIsCurrent()) return null;
|
|
1007
|
+
|
|
1008
|
+
const bufferedAheadSeconds = bufferCaptureValid ? Number(viewerReading?.bufferedAheadSec) : null;
|
|
1009
|
+
const positionSeconds = bufferCaptureValid && Number.isFinite(viewerReading?.positionSeconds)
|
|
947
1010
|
? viewerReading.positionSeconds
|
|
948
1011
|
: this.#host.viewerSecondsOn(session, consumerId, now);
|
|
949
1012
|
// Both separately delivered tracks can run short independently. Use the
|
|
@@ -970,7 +1033,7 @@ export class ViewerRequests {
|
|
|
970
1033
|
positionSeconds,
|
|
971
1034
|
durationSeconds: session.file.durationSeconds,
|
|
972
1035
|
bufferedAheadSeconds,
|
|
973
|
-
bufferLimitSeconds: viewerReading?.bufferLimitSeconds,
|
|
1036
|
+
bufferLimitSeconds: bufferCaptureValid ? viewerReading?.bufferLimitSeconds : null,
|
|
974
1037
|
reserveSeconds,
|
|
975
1038
|
lookaheadSeconds: this.#host.lookaheadSeconds,
|
|
976
1039
|
// Disk room remaining after stock reserved for other active outputs.
|
|
@@ -978,7 +1041,7 @@ export class ViewerRequests {
|
|
|
978
1041
|
requiredAudio: requiresSeparateAudio,
|
|
979
1042
|
sources: [...sourceMeasurements.values()],
|
|
980
1043
|
tracks,
|
|
981
|
-
linkReadings: measurement.link.snapshot()
|
|
1044
|
+
linkReadings: viewerReading?.linkCapture?.validity === "valid" ? measurement.link.snapshot() : null
|
|
982
1045
|
};
|
|
983
1046
|
// A subtitle track chosen inside THIS file is the last input the forecast
|
|
984
1047
|
// waits for. A subtitle FILE beside it is named by its own index and is
|
|
@@ -991,13 +1054,25 @@ export class ViewerRequests {
|
|
|
991
1054
|
this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
|
|
992
1055
|
}
|
|
993
1056
|
const predictMs = performance.now() - startedAt;
|
|
994
|
-
this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
|
|
1057
|
+
this.#host.noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
|
|
995
1058
|
logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
|
|
996
1059
|
`(the torrent thread's arrival forecast ${Math.round(parts.workerForecastMs)}ms), stream rate ${Math.round(parts.qualityMs)}ms, ` +
|
|
997
1060
|
`start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
|
|
998
1061
|
const forecast = withSubtitleReadiness(predicted, subtitle,
|
|
999
1062
|
subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
|
|
1000
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
|
+
};
|
|
1001
1076
|
forecast.operations = outputs.flatMap((output, index) => {
|
|
1002
1077
|
const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
|
|
1003
1078
|
processedSeconds: tracks[index].processedSeconds,
|
|
@@ -1014,11 +1089,9 @@ export class ViewerRequests {
|
|
|
1014
1089
|
outputCodec: output.spec.audio.transcode ? "aac" : null }] : [])
|
|
1015
1090
|
];
|
|
1016
1091
|
});
|
|
1017
|
-
const generation = consumerId && viewer?.assignments
|
|
1018
|
-
? this.#host.generationOfRequest(consumerId) : null;
|
|
1019
1092
|
const earlyWaitId = Number.isSafeInteger(generation)
|
|
1020
1093
|
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
1021
|
-
if (earlyWaitId && this.#
|
|
1094
|
+
if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
|
|
1022
1095
|
const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
|
|
1023
1096
|
const videoOperation = forecast.operations.find(operation => operation.track === "video");
|
|
1024
1097
|
const audioOperation = forecast.operations.find(operation => operation.track === "audio");
|
|
@@ -1026,6 +1099,7 @@ export class ViewerRequests {
|
|
|
1026
1099
|
sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
|
|
1027
1100
|
fileName: session.file.relativePath ?? session.file.name ?? "",
|
|
1028
1101
|
fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
|
|
1102
|
+
viewerToken: viewer,
|
|
1029
1103
|
linkMbps: viewerReading?.linkMbps,
|
|
1030
1104
|
linkSampleAgeMs: Number.isFinite(linkMeasuredAt) ? Math.max(0, Date.now() - linkMeasuredAt) : undefined,
|
|
1031
1105
|
processing: {
|
|
@@ -1163,85 +1237,6 @@ export class ViewerRequests {
|
|
|
1163
1237
|
* @param {number} positionSeconds
|
|
1164
1238
|
* @returns {object[]}
|
|
1165
1239
|
*/
|
|
1166
|
-
#segmentsOf(output, sessionInit, inventory, positionSeconds) {
|
|
1167
|
-
const timeline = output.timeline;
|
|
1168
|
-
const count = timeline?.segmentCount ?? 0;
|
|
1169
|
-
let forecast = this.#forecastPieces.get(output);
|
|
1170
|
-
if (!forecast) {
|
|
1171
|
-
forecast = { pieces: [], geometry: {} };
|
|
1172
|
-
this.#forecastPieces.set(output, forecast);
|
|
1173
|
-
}
|
|
1174
|
-
const kept = forecast.pieces;
|
|
1175
|
-
const segments = [];
|
|
1176
|
-
const lookahead = Number(this.#host.lookaheadSeconds);
|
|
1177
|
-
const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
|
|
1178
|
-
const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
|
|
1179
|
-
let low = 0, high = count;
|
|
1180
|
-
while (low < high) {
|
|
1181
|
-
const middle = (low + high) >>> 1;
|
|
1182
|
-
if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
|
|
1183
|
-
else high = middle;
|
|
1184
|
-
}
|
|
1185
|
-
const first = Math.max(0, low - 1);
|
|
1186
|
-
low = first;
|
|
1187
|
-
high = count;
|
|
1188
|
-
while (low < high) {
|
|
1189
|
-
const middle = (low + high) >>> 1;
|
|
1190
|
-
if (timeline.publishedStartOf(middle) < end) low = middle + 1;
|
|
1191
|
-
else high = middle;
|
|
1192
|
-
}
|
|
1193
|
-
const last = low;
|
|
1194
|
-
const indexes = new Set([0]);
|
|
1195
|
-
for (let index = first; index < last; index++) indexes.add(index);
|
|
1196
|
-
for (const index of indexes) {
|
|
1197
|
-
const endSeconds = timeline.publishedStartOf(index + 1);
|
|
1198
|
-
// Only a made piece has media intervals to read. Asked of every piece,
|
|
1199
|
-
// each one not made cost a `stat` of its file in the store's safety net
|
|
1200
|
-
// for a missed directory event: 1327 per poll per output, 12.6 s of 300 s
|
|
1201
|
-
// of the main thread at a cold start on Home Assistant (r61,
|
|
1202
|
-
// torrent-tv/meta#178), while requests waited behind it.
|
|
1203
|
-
const ranges = inventory.has(index) ? this.#host.encodeRuns.finishedMediaRangesOf(output, index) : undefined;
|
|
1204
|
-
const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
|
|
1205
|
-
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
|
|
1206
|
-
// The player can request a fragment whose media timestamps extend a few
|
|
1207
|
-
// frames past its nominal playlist end while seeking to the next piece.
|
|
1208
|
-
if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
|
|
1209
|
-
Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) &&
|
|
1210
|
-
!this.#host.playbackReadiness.endsWithinPlayerSeekTolerance(endSeconds, positionSeconds) &&
|
|
1211
|
-
endSeconds < positionSeconds) {
|
|
1212
|
-
continue;
|
|
1213
|
-
}
|
|
1214
|
-
const startSeconds = timeline.publishedStartOf(index);
|
|
1215
|
-
const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
|
|
1216
|
-
const previous = kept[index];
|
|
1217
|
-
if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
|
|
1218
|
-
previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
|
|
1219
|
-
segments.push(previous);
|
|
1220
|
-
continue;
|
|
1221
|
-
}
|
|
1222
|
-
const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
|
|
1223
|
-
kept[index] = segment;
|
|
1224
|
-
segments.push(segment);
|
|
1225
|
-
}
|
|
1226
|
-
if (kept.length > count) kept.length = count;
|
|
1227
|
-
return segments;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
#rateStateFor(session, consumerId) {
|
|
1231
|
-
let byViewer = this.#rateStateBySession.get(session);
|
|
1232
|
-
if (!byViewer) {
|
|
1233
|
-
byViewer = new Map();
|
|
1234
|
-
this.#rateStateBySession.set(session, byViewer);
|
|
1235
|
-
}
|
|
1236
|
-
const id = typeof consumerId === "string" ? consumerId : "";
|
|
1237
|
-
let state = byViewer.get(id);
|
|
1238
|
-
if (!state) {
|
|
1239
|
-
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
|
|
1240
|
-
byViewer.set(id, state);
|
|
1241
|
-
}
|
|
1242
|
-
return state;
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
1240
|
/**
|
|
1246
1241
|
* The encoder's rate for the forecast: the output's whole window of readings,
|
|
1247
1242
|
* summarized again on each poll. A run that ended between two polls took its
|
|
@@ -1252,7 +1247,20 @@ export class ViewerRequests {
|
|
|
1252
1247
|
#trackRateReadings(output, now) {
|
|
1253
1248
|
const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
|
|
1254
1249
|
const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
|
|
1255
|
-
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" })) };
|
|
1256
1264
|
// ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
|
|
1257
1265
|
// encoder, and it is not kept among the readings. Added on every poll
|
|
1258
1266
|
// between runs, it put the machine's share of that moment — 1 % at a
|
|
@@ -1262,7 +1270,9 @@ export class ViewerRequests {
|
|
|
1262
1270
|
if (Number.isFinite(projected) && projected > 0) {
|
|
1263
1271
|
const seed = new this.#host.playbackReadiness.RateTrend();
|
|
1264
1272
|
seed.add(now, projected);
|
|
1265
|
-
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 }] };
|
|
1266
1276
|
}
|
|
1267
1277
|
// Progress over wall time includes source waits, which the forecast prices
|
|
1268
1278
|
// separately. Keep the rate unknown until a measured or calibrated
|
|
@@ -1270,93 +1280,6 @@ export class ViewerRequests {
|
|
|
1270
1280
|
return null;
|
|
1271
1281
|
}
|
|
1272
1282
|
|
|
1273
|
-
/**
|
|
1274
|
-
* What the swarm can give this source. A rate measured while our own
|
|
1275
|
-
* selection held a connection back measures our asking: on a cold Matroska
|
|
1276
|
-
* it read 0.4-1.3 MB/s while the same swarm later gave 12-14 MB/s, and the
|
|
1277
|
-
* forecast swung from 0.9 s to 19 075 s (torrent-tv/meta#178). So it is read
|
|
1278
|
-
* from the time no connection was held back (`SelectionLimit`), and a later
|
|
1279
|
-
* such reading lowers it as readily as it raises it. Until there is one, the
|
|
1280
|
-
* rate delivered stands, which is the least the swarm can give.
|
|
1281
|
-
*/
|
|
1282
|
-
#sourceRateReadings(state, id, stats, now) {
|
|
1283
|
-
let source = state.sources.get(id);
|
|
1284
|
-
if (!source) {
|
|
1285
|
-
const { RateTrend, SessionRate } = this.#host.playbackReadiness;
|
|
1286
|
-
source = { delivered: new RateTrend(this.#host.lookaheadSeconds), free: new RateTrend(this.#host.lookaheadSeconds),
|
|
1287
|
-
// The same readings over the whole session, which the rest of the film is priced by.
|
|
1288
|
-
deliveredAll: new SessionRate(), freeAll: new SessionRate(), limit: null };
|
|
1289
|
-
state.sources.set(id, source);
|
|
1290
|
-
}
|
|
1291
|
-
if (Number.isFinite(stats?.downloadSpeed) && stats.downloadSpeed >= 0) {
|
|
1292
|
-
source.delivered.add(now, stats.downloadSpeed);
|
|
1293
|
-
source.deliveredAll.add(now, stats.downloadSpeed);
|
|
1294
|
-
}
|
|
1295
|
-
const limit = stats?.selectionLimit;
|
|
1296
|
-
if (limit && source.limit && limit.freeMs > source.limit.freeMs) {
|
|
1297
|
-
const free = Math.max(0, limit.freeBytes - source.limit.freeBytes) * 1000 / (limit.freeMs - source.limit.freeMs);
|
|
1298
|
-
source.free.add(now, free);
|
|
1299
|
-
source.freeAll.add(now, free);
|
|
1300
|
-
}
|
|
1301
|
-
// Totals that went back belong to a torrent added again: read on from them.
|
|
1302
|
-
if (limit) source.limit = limit;
|
|
1303
|
-
return sourceService(source.free.snapshot(), source.delivered.snapshot(),
|
|
1304
|
-
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
/** The source's service over the whole session, chosen by the same rule as the recent one. */
|
|
1308
|
-
#sourceTailRateReadings(state, id, now) {
|
|
1309
|
-
const source = state.sources.get(id);
|
|
1310
|
-
if (!source) return null;
|
|
1311
|
-
return sourceService(source.freeAll.snapshot(), source.deliveredAll.snapshot(),
|
|
1312
|
-
(summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
/** Both summaries a source's service was chosen from, for the forecast's log line. */
|
|
1316
|
-
#sourceRateSummaries(state, id) {
|
|
1317
|
-
const source = state.sources.get(id);
|
|
1318
|
-
return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
/**
|
|
1323
|
-
* Keep a wait's measurements when a later response omits a field. Zero is a
|
|
1324
|
-
* measurement for rates, peers and byte counts; only null or an invalid value
|
|
1325
|
-
* means that this response did not measure the field.
|
|
1326
|
-
*
|
|
1327
|
-
* @param {object | null | undefined} previous
|
|
1328
|
-
* @param {object} incoming
|
|
1329
|
-
* @param {number} ageElapsedMs
|
|
1330
|
-
* @returns {object}
|
|
1331
|
-
*/
|
|
1332
|
-
function mergeWaitFeatures(previous, incoming, ageElapsedMs) {
|
|
1333
|
-
const merged = { ...(previous ?? {}) };
|
|
1334
|
-
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
1335
|
-
if (value === null || value === undefined) {
|
|
1336
|
-
if (!Object.hasOwn(merged, key)) merged[key] = null;
|
|
1337
|
-
continue;
|
|
1338
|
-
}
|
|
1339
|
-
if (key === "extension") {
|
|
1340
|
-
if (typeof value === "string" && value.length > 0) merged[key] = value;
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
if (key === "fileAvailable") {
|
|
1344
|
-
if (typeof value === "boolean") merged[key] = value;
|
|
1345
|
-
continue;
|
|
1346
|
-
}
|
|
1347
|
-
if (key === "videoOperation" || key === "audioOperation") {
|
|
1348
|
-
if (value === "copy" || value === "encode") merged[key] = value;
|
|
1349
|
-
continue;
|
|
1350
|
-
}
|
|
1351
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 ||
|
|
1352
|
-
((key === "fileLength" || key === "linkMbps") && value === 0)) continue;
|
|
1353
|
-
merged[key] = value;
|
|
1354
|
-
}
|
|
1355
|
-
const hasIncomingAge = Number.isFinite(incoming?.linkSampleAgeMs) && incoming.linkSampleAgeMs >= 0;
|
|
1356
|
-
if (Number.isFinite(merged.linkSampleAgeMs) && !hasIncomingAge) {
|
|
1357
|
-
merged.linkSampleAgeMs += Number.isFinite(ageElapsedMs) ? Math.max(0, ageElapsedMs) : 0;
|
|
1358
|
-
}
|
|
1359
|
-
return merged;
|
|
1360
1283
|
}
|
|
1361
1284
|
|
|
1362
1285
|
/**
|
|
@@ -1399,8 +1322,3 @@ function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
|
|
|
1399
1322
|
* @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
|
|
1400
1323
|
* @returns {object | null}
|
|
1401
1324
|
*/
|
|
1402
|
-
export function sourceService(free, delivered, rateOf) {
|
|
1403
|
-
if (!free) return delivered;
|
|
1404
|
-
if (!delivered) return free;
|
|
1405
|
-
return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
|
|
1406
|
-
}
|