@torrent-tv/proxy 2.107.51 → 2.107.53
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 +4 -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 +80 -288
- package/services/server/wire-outputs.js +17 -0
- package/services/torrent/download/TailRepeat.js +155 -0
- package/services/torrent/torrent-pool.js +6 -4
- package/services/torrent/worker/WebTorrentTailRepeatAdapter.js +216 -0
- package/services/viewer/OutputForecastGeometry.js +87 -0
- package/services/viewer/ViewerPlaybackState.js +215 -0
- package/services/torrent/worker/fastest-wires.js +0 -398
- package/services/torrent/worker/tail-repeat.js +0 -171
|
@@ -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, {
|
|
@@ -119,53 +113,43 @@ function segmentAddressOf(output, fileName) {
|
|
|
119
113
|
export class ViewerRequests {
|
|
120
114
|
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
121
115
|
#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
116
|
/**
|
|
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).
|
|
117
|
+
* @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
118
|
*/
|
|
133
|
-
|
|
119
|
+
constructor(host) {
|
|
120
|
+
this.#host = host;
|
|
121
|
+
}
|
|
134
122
|
|
|
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() });
|
|
123
|
+
viewerDeparted(viewer) {
|
|
124
|
+
if (viewer) this.#host.viewerPlaybackState.forgetViewer(viewer);
|
|
145
125
|
}
|
|
146
126
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
* they stand (`playback-readiness.js` `timescaleOfPieces`).
|
|
151
|
-
*
|
|
152
|
-
* @type {WeakMap<object, { pieces: object[], geometry: object }>}
|
|
153
|
-
*/
|
|
154
|
-
#forecastPieces = new WeakMap();
|
|
127
|
+
viewerTokenOf(consumerId) {
|
|
128
|
+
return consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
129
|
+
}
|
|
155
130
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
131
|
+
outputViewerDeparted(viewer, output) {
|
|
132
|
+
if (!viewer || !output) return;
|
|
133
|
+
if (this.#host.viewers.get(viewer.id) === viewer) {
|
|
134
|
+
this.#host.viewerPlaybackState.forgetViewerOutput(viewer, output);
|
|
135
|
+
} else {
|
|
136
|
+
this.#host.viewerPlaybackState.forgetViewer(viewer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
outputDisposed(output) {
|
|
141
|
+
this.#host.viewerPlaybackState.forgetOutput(output);
|
|
142
|
+
this.#host.outputForecastGeometry.forget(output);
|
|
161
143
|
}
|
|
162
144
|
|
|
163
145
|
/** Record the facts known while listing a source, without producing an ETA. */
|
|
164
|
-
noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs
|
|
146
|
+
noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
|
|
147
|
+
viewerToken }) {
|
|
165
148
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
|
|
166
149
|
!Number.isSafeInteger(fileIndex) || fileIndex < 0) return;
|
|
167
150
|
const waitId = `${sourceKey}:${fileIndex}:${generation}`;
|
|
168
|
-
const
|
|
151
|
+
const viewer = this.#currentViewerToken(consumerId, viewerToken);
|
|
152
|
+
if (!viewer) return;
|
|
169
153
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
170
154
|
const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
|
|
171
155
|
const features = {
|
|
@@ -179,10 +163,8 @@ export class ViewerRequests {
|
|
|
179
163
|
headerRemaining: null,
|
|
180
164
|
peers: null
|
|
181
165
|
};
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
this.#recordWaitSnapshot(context);
|
|
185
|
-
this.#pruneWaitObservations();
|
|
166
|
+
this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
|
|
167
|
+
this.#host.viewerPlaybackState.pruneWaits();
|
|
186
168
|
}
|
|
187
169
|
|
|
188
170
|
/**
|
|
@@ -194,10 +176,11 @@ export class ViewerRequests {
|
|
|
194
176
|
* @returns {object | null}
|
|
195
177
|
*/
|
|
196
178
|
noteWaitSourceSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
|
|
197
|
-
stats = {}, linkMbps, linkSampleAgeMs, processing = {} }) {
|
|
179
|
+
stats = {}, linkMbps, linkSampleAgeMs, processing = {}, viewerToken }) {
|
|
198
180
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return;
|
|
199
181
|
const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
|
|
200
|
-
const
|
|
182
|
+
const viewer = this.#currentViewerToken(consumerId, viewerToken);
|
|
183
|
+
if (!viewer) return;
|
|
201
184
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
202
185
|
const link = linkMeasurementOf(this.#host.viewers.get(consumerId), linkMbps, linkSampleAgeMs);
|
|
203
186
|
const features = {
|
|
@@ -218,10 +201,8 @@ export class ViewerRequests {
|
|
|
218
201
|
videoSpeedX: processing.videoSpeedX ?? null,
|
|
219
202
|
audioSpeedX: processing.audioSpeedX ?? null
|
|
220
203
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
this.#recordWaitSnapshot(context);
|
|
224
|
-
this.#pruneWaitObservations();
|
|
204
|
+
this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey, fileIndex, waitId, features });
|
|
205
|
+
this.#host.viewerPlaybackState.pruneWaits();
|
|
225
206
|
}
|
|
226
207
|
|
|
227
208
|
/**
|
|
@@ -235,49 +216,14 @@ export class ViewerRequests {
|
|
|
235
216
|
* @returns {void}
|
|
236
217
|
*/
|
|
237
218
|
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;
|
|
219
|
+
const viewer = this.#host.viewers.get(consumerId);
|
|
220
|
+
if (!viewer) return;
|
|
221
|
+
this.#host.viewerPlaybackState.noteFirstFrame({ viewer, generation, waitId, wait, elapsedMs, stages,
|
|
222
|
+
record: observation => this.#host.noteFirstFrame?.({ ...observation, stages }) });
|
|
271
223
|
}
|
|
272
224
|
|
|
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;
|
|
225
|
+
#currentViewerToken(consumerId, viewerToken) {
|
|
226
|
+
return viewerToken && this.#host.viewers.get(consumerId) === viewerToken ? viewerToken : null;
|
|
281
227
|
}
|
|
282
228
|
|
|
283
229
|
#preparedOutputBytes(sourceKey, fileIndex) {
|
|
@@ -307,14 +253,6 @@ export class ViewerRequests {
|
|
|
307
253
|
return bytes;
|
|
308
254
|
}
|
|
309
255
|
|
|
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
256
|
/**
|
|
319
257
|
* Open an output of a file for this viewer and put them on it.
|
|
320
258
|
*
|
|
@@ -548,10 +486,10 @@ export class ViewerRequests {
|
|
|
548
486
|
if (!Number.isSafeInteger(generation) || !file || typeof file.sourceKey !== "string" ||
|
|
549
487
|
!Number.isSafeInteger(file.fileIndex) || file.fileIndex < 0) return;
|
|
550
488
|
const waitId = `${file.sourceKey}:${file.fileIndex}:${generation}`;
|
|
551
|
-
const key = `${consumerId}:${waitId}`;
|
|
552
489
|
const fileName = file.relativePath ?? file.name ?? "";
|
|
553
490
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
554
491
|
const viewer = this.#host.viewers.get(consumerId);
|
|
492
|
+
if (!viewer) return;
|
|
555
493
|
const link = linkMeasurementOf(viewer);
|
|
556
494
|
const features = {
|
|
557
495
|
extension,
|
|
@@ -570,10 +508,9 @@ export class ViewerRequests {
|
|
|
570
508
|
};
|
|
571
509
|
// A seek is a new observation group even when a caller repeats the same
|
|
572
510
|
// 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);
|
|
511
|
+
this.#host.viewerPlaybackState.deleteWait(viewer, waitId);
|
|
512
|
+
this.#host.viewerPlaybackState.recordWaitSnapshot({ viewer, consumerId, generation, sourceKey: file.sourceKey,
|
|
513
|
+
fileIndex: file.fileIndex, waitId, features });
|
|
577
514
|
}
|
|
578
515
|
|
|
579
516
|
/**
|
|
@@ -690,6 +627,8 @@ export class ViewerRequests {
|
|
|
690
627
|
// without this the base session would idle out from under its own variants.
|
|
691
628
|
const session = this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs });
|
|
692
629
|
this.#host.outputs.touch(session);
|
|
630
|
+
const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
631
|
+
const stillOwnsProgress = () => this.#stillOwnsProgress(named, session, consumerId, viewer);
|
|
693
632
|
const audio = this.#host.renditions.servesAudioSeparately(session)
|
|
694
633
|
? this.#host.renditions.playbackAudioOutputFor(session, consumerId) : null;
|
|
695
634
|
const failed = [session, audio].find(output => output && this.#host.encodeRuns.wireStateOf(output) === "failed");
|
|
@@ -709,8 +648,11 @@ export class ViewerRequests {
|
|
|
709
648
|
// Observed output bitrate (Mbit/s) from recently completed segments. The
|
|
710
649
|
// proxy uses it for the viewer-link budget and the readiness forecast.
|
|
711
650
|
const outputMbps = await this.#host.quality.observedStreamMbps(session);
|
|
651
|
+
if (!stillOwnsProgress()) return null;
|
|
712
652
|
const progress = this.#host.encodeRuns.progressOf(session);
|
|
713
|
-
const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps
|
|
653
|
+
const playbackReadiness = await this.#playbackReadinessFor(session, consumerId, progress, outputMbps,
|
|
654
|
+
viewer, stillOwnsProgress);
|
|
655
|
+
if (!stillOwnsProgress()) return null;
|
|
714
656
|
return {
|
|
715
657
|
// The id the caller asked about, not the variant it was answered from —
|
|
716
658
|
// the browser tracks its sessions by the id it was given.
|
|
@@ -795,7 +737,16 @@ export class ViewerRequests {
|
|
|
795
737
|
};
|
|
796
738
|
}
|
|
797
739
|
|
|
798
|
-
|
|
740
|
+
#stillOwnsProgress(named, session, consumerId, viewer) {
|
|
741
|
+
if (this.#host.outputs.get(named.id) !== named || this.#host.outputs.get(session.id) !== session) return false;
|
|
742
|
+
if (!viewer) return !consumerId || !this.#host.viewers.get(consumerId);
|
|
743
|
+
return this.#host.viewers.get(consumerId) === viewer &&
|
|
744
|
+
(!(viewer.outputs instanceof Set) || viewer.outputs.has(named.id)) &&
|
|
745
|
+
this.#host.activeOutputFor({ base: named, consumerId, outputs: this.#host.outputs }) === session;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async #playbackReadinessFor(session, consumerId, progress, outputMbps, viewer, stillOwnsProgress) {
|
|
749
|
+
if (!stillOwnsProgress()) return null;
|
|
799
750
|
const waitStartedAt = performance.now();
|
|
800
751
|
const waitFields = { process: "P14", sessionId: session.id, consumerId };
|
|
801
752
|
this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
|
|
@@ -806,12 +757,13 @@ export class ViewerRequests {
|
|
|
806
757
|
// held viewer's answer took 11.2 s while its forecast took 0.1-0.4 s and
|
|
807
758
|
// the answer crossed the connection in 21 ms (r50, torrent-tv/meta#178).
|
|
808
759
|
const parts = { enteredAt: performance.now(), statsMs: 0, workerForecastMs: 0, qualityMs: 0 };
|
|
809
|
-
const viewer = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
810
760
|
const viewerReading = viewer?.linkReading() ?? null;
|
|
811
|
-
const measurement =
|
|
761
|
+
const measurement = viewer
|
|
762
|
+
? this.#host.viewerPlaybackState.rateState(viewer, session, this.#host.playbackReadiness, this.#host.lookaheadSeconds)
|
|
763
|
+
: { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), sources: new Map() };
|
|
812
764
|
const linkAt = Number(viewerReading?.linkSampleMeasuredAt);
|
|
813
765
|
if (Number.isFinite(viewerReading?.linkSampleMbps) && viewerReading.linkSampleMbps > 0 && Number.isFinite(linkAt)) {
|
|
814
|
-
|
|
766
|
+
this.#host.viewerPlaybackState.addLinkReading(measurement, linkAt, viewerReading.linkSampleMbps);
|
|
815
767
|
}
|
|
816
768
|
|
|
817
769
|
const outputs = [session];
|
|
@@ -828,6 +780,7 @@ export class ViewerRequests {
|
|
|
828
780
|
let primarySourceStats = null;
|
|
829
781
|
const tracks = [];
|
|
830
782
|
for (const output of outputs) {
|
|
783
|
+
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
831
784
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
832
785
|
const trackRates = this.#trackRateReadings(output, now);
|
|
833
786
|
const trackName = output === session ? "video" : "audio";
|
|
@@ -840,7 +793,7 @@ export class ViewerRequests {
|
|
|
840
793
|
const placedByPlayer = Boolean(servedInit?.length);
|
|
841
794
|
const sessionInit = placedByPlayer ? servedInit : null;
|
|
842
795
|
const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
|
|
843
|
-
const segments = this.#segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
|
|
796
|
+
const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory, Number.isFinite(viewerReading?.positionSeconds)
|
|
844
797
|
? viewerReading.positionSeconds : this.#host.viewerSecondsOn(session, consumerId, now));
|
|
845
798
|
const sourceIndexes = [...output.spec.sourceFileIndexes];
|
|
846
799
|
if (sourceIndexes.length === 0) {
|
|
@@ -861,6 +814,7 @@ export class ViewerRequests {
|
|
|
861
814
|
} catch {
|
|
862
815
|
stats = null;
|
|
863
816
|
}
|
|
817
|
+
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
864
818
|
if (output === session && fileIndex === session.file.fileIndex) primarySourceStats = stats;
|
|
865
819
|
parts.statsMs += performance.now() - statsAt;
|
|
866
820
|
parts.workerForecastMs += Number(stats?.forecastMs) || 0;
|
|
@@ -882,9 +836,14 @@ export class ViewerRequests {
|
|
|
882
836
|
Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
|
|
883
837
|
? stats.fileLength / session.file.durationSeconds : null,
|
|
884
838
|
pieceLength: stats?.pieceLength,
|
|
885
|
-
downloadRateReadings: this.#
|
|
886
|
-
|
|
887
|
-
|
|
839
|
+
downloadRateReadings: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
|
|
840
|
+
? this.#host.viewerPlaybackState.sourceRateReadings(measurement, output.file.sourceKey, stats, now,
|
|
841
|
+
this.#host.playbackReadiness, this.#host.lookaheadSeconds) : null,
|
|
842
|
+
tailRateReadings: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
|
|
843
|
+
? this.#host.viewerPlaybackState.sourceTailRateReadings(measurement, output.file.sourceKey, now,
|
|
844
|
+
this.#host.playbackReadiness) : null,
|
|
845
|
+
downloadRateSummaries: viewer && this.#host.viewerPlaybackState.owns(viewer, session, measurement)
|
|
846
|
+
? this.#host.viewerPlaybackState.sourceRateSummaries(measurement, output.file.sourceKey) : null,
|
|
888
847
|
downloadForecast,
|
|
889
848
|
});
|
|
890
849
|
}
|
|
@@ -893,6 +852,7 @@ export class ViewerRequests {
|
|
|
893
852
|
const observedMbps = output === session
|
|
894
853
|
? outputMbps
|
|
895
854
|
: await this.#host.quality.observedStreamMbps(output);
|
|
855
|
+
if (!stillOwnsProgress() || this.#host.outputs.get(output.id) !== output) return null;
|
|
896
856
|
parts.qualityMs += performance.now() - qualityAt;
|
|
897
857
|
const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
|
|
898
858
|
const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
|
|
@@ -936,12 +896,14 @@ export class ViewerRequests {
|
|
|
936
896
|
segmentCount: output.timeline?.segmentCount ?? segments.length,
|
|
937
897
|
readings: trackRates,
|
|
938
898
|
segments,
|
|
939
|
-
geometry: this.#
|
|
899
|
+
geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
|
|
940
900
|
readySegmentIndices: [...inventory.keys()],
|
|
941
901
|
segmentSizesBytes: inventory
|
|
942
902
|
});
|
|
943
903
|
}
|
|
944
904
|
|
|
905
|
+
if (!stillOwnsProgress()) return null;
|
|
906
|
+
|
|
945
907
|
const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
|
|
946
908
|
const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
|
|
947
909
|
? viewerReading.positionSeconds
|
|
@@ -991,7 +953,7 @@ export class ViewerRequests {
|
|
|
991
953
|
this.#host.notePlaybackStorageDemand?.(session.outputKey ?? session.id, consumerId, predicted.cushionBytes);
|
|
992
954
|
}
|
|
993
955
|
const predictMs = performance.now() - startedAt;
|
|
994
|
-
this.#noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
|
|
956
|
+
this.#host.noteForecast(predictMs, tracks.map(track => track.geometry?.reason ?? "none"));
|
|
995
957
|
logger.info(`readiness parts ${session.id} consumer=${consumerId}: source stats ${Math.round(parts.statsMs)}ms ` +
|
|
996
958
|
`(the torrent thread's arrival forecast ${Math.round(parts.workerForecastMs)}ms), stream rate ${Math.round(parts.qualityMs)}ms, ` +
|
|
997
959
|
`start forecast ${Math.round(predictMs)}ms, in all ${Math.round(performance.now() - parts.enteredAt)}ms`);
|
|
@@ -1018,7 +980,7 @@ export class ViewerRequests {
|
|
|
1018
980
|
? this.#host.generationOfRequest(consumerId) : null;
|
|
1019
981
|
const earlyWaitId = Number.isSafeInteger(generation)
|
|
1020
982
|
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
1021
|
-
if (earlyWaitId && this.#
|
|
983
|
+
if (earlyWaitId && this.#host.viewerPlaybackState.hasWait(viewer, earlyWaitId) && primarySourceStats) {
|
|
1022
984
|
const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
|
|
1023
985
|
const videoOperation = forecast.operations.find(operation => operation.track === "video");
|
|
1024
986
|
const audioOperation = forecast.operations.find(operation => operation.track === "audio");
|
|
@@ -1026,6 +988,7 @@ export class ViewerRequests {
|
|
|
1026
988
|
sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
|
|
1027
989
|
fileName: session.file.relativePath ?? session.file.name ?? "",
|
|
1028
990
|
fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
|
|
991
|
+
viewerToken: viewer,
|
|
1029
992
|
linkMbps: viewerReading?.linkMbps,
|
|
1030
993
|
linkSampleAgeMs: Number.isFinite(linkMeasuredAt) ? Math.max(0, Date.now() - linkMeasuredAt) : undefined,
|
|
1031
994
|
processing: {
|
|
@@ -1163,85 +1126,6 @@ export class ViewerRequests {
|
|
|
1163
1126
|
* @param {number} positionSeconds
|
|
1164
1127
|
* @returns {object[]}
|
|
1165
1128
|
*/
|
|
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
1129
|
/**
|
|
1246
1130
|
* The encoder's rate for the forecast: the output's whole window of readings,
|
|
1247
1131
|
* summarized again on each poll. A run that ended between two polls took its
|
|
@@ -1270,93 +1154,6 @@ export class ViewerRequests {
|
|
|
1270
1154
|
return null;
|
|
1271
1155
|
}
|
|
1272
1156
|
|
|
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
1157
|
}
|
|
1361
1158
|
|
|
1362
1159
|
/**
|
|
@@ -1399,8 +1196,3 @@ function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
|
|
|
1399
1196
|
* @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
|
|
1400
1197
|
* @returns {object | null}
|
|
1401
1198
|
*/
|
|
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
|
-
}
|
|
@@ -107,6 +107,9 @@ import { describeProcessor, processorBetween, sampleProcessor } from "../storage
|
|
|
107
107
|
import { ProcessorBudget } from "../encode/ProcessorBudget.js";
|
|
108
108
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
109
109
|
import { secondsUntilClosed } from "../encode/piece-close-time.js";
|
|
110
|
+
import { ViewerPlaybackState } from "../viewer/ViewerPlaybackState.js";
|
|
111
|
+
import { OutputForecastGeometry } from "../viewer/OutputForecastGeometry.js";
|
|
112
|
+
import { ForecastDiagnostics } from "./ForecastDiagnostics.js";
|
|
110
113
|
|
|
111
114
|
// Where a variant and an audio rendition live under a session — `v/<height>/…`
|
|
112
115
|
// and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
|
|
@@ -249,6 +252,14 @@ export function wireOutputs({
|
|
|
249
252
|
// output key owns its record, and the graph releases it at the last session.
|
|
250
253
|
const waits = new WaitLedger();
|
|
251
254
|
const parts = {};
|
|
255
|
+
parts.viewerPlaybackState = new ViewerPlaybackState();
|
|
256
|
+
parts.outputForecastGeometry = new OutputForecastGeometry({
|
|
257
|
+
lookaheadSeconds: LOOKAHEAD_PAUSE_SECONDS,
|
|
258
|
+
finishedMediaRangesOf: (output, index) => parts.encodeRuns.finishedMediaRangesOf(output, index),
|
|
259
|
+
sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
|
|
260
|
+
endsWithinPlayerSeekTolerance
|
|
261
|
+
});
|
|
262
|
+
parts.forecastDiagnostics = new ForecastDiagnostics({ logger });
|
|
252
263
|
const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
|
|
253
264
|
sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
|
|
254
265
|
}) ?? []).find((track) => track.fileIndex === output.spec.audio?.fileIndex &&
|
|
@@ -573,6 +584,9 @@ export function wireOutputs({
|
|
|
573
584
|
planEncodersSoon: () => parts.encodeRuns.planEncodersSoon(),
|
|
574
585
|
invalidateWaits: (output) => parts.serving.invalidateWaits(output),
|
|
575
586
|
clearWaits: () => waits.clear(),
|
|
587
|
+
forgetForecastOutput: (output) => parts.viewerRequests.outputDisposed(output),
|
|
588
|
+
viewerDeparted: (viewer) => parts.viewerRequests.viewerDeparted(viewer),
|
|
589
|
+
outputViewerDeparted: (viewer, output) => parts.viewerRequests.outputViewerDeparted(viewer, output),
|
|
576
590
|
segmentFormatOfKey,
|
|
577
591
|
// Where the viewers of one output stand, in its segments: what the store
|
|
578
592
|
// the output retention policy reads to give disk back in the right order.
|
|
@@ -632,6 +646,9 @@ export function wireOutputs({
|
|
|
632
646
|
// What a viewer asks for: an output of a file, a position, a report of progress.
|
|
633
647
|
parts.viewerRequests = new ViewerRequests({
|
|
634
648
|
observeWait,
|
|
649
|
+
viewerPlaybackState: parts.viewerPlaybackState,
|
|
650
|
+
outputForecastGeometry: parts.outputForecastGeometry,
|
|
651
|
+
noteForecast: (...args) => parts.forecastDiagnostics.note(...args),
|
|
635
652
|
// Whether this machine has a place for one more viewer on this output —
|
|
636
653
|
// asked in the same synchronous stretch as they are put on it.
|
|
637
654
|
admitsWatching: (output) => parts.admission.admitsWatching(output?.outputKey ?? ""),
|