@torrent-tv/proxy 2.107.45 → 2.107.47
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
CHANGED
|
@@ -77,17 +77,15 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
77
77
|
const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
|
|
78
78
|
const linkMbps = Number(req.query?.linkMbps);
|
|
79
79
|
const linkSampleAgeMs = Number(req.query?.linkSampleAgeMs);
|
|
80
|
-
const waitElapsedMs = Number(req.query?.waitElapsedMs);
|
|
81
80
|
const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
|
|
82
81
|
? { linkMbps, linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null }
|
|
83
82
|
: {};
|
|
84
|
-
const earlyWaitAge = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? { waitElapsedMs } : {};
|
|
85
83
|
const files = contents.files().map(file => {
|
|
86
84
|
if (!consumerId || !Number.isSafeInteger(generation) || file.kind !== "video" || file.excludedReason) return file;
|
|
87
|
-
|
|
85
|
+
viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey,
|
|
88
86
|
fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length,
|
|
89
|
-
...earlyLink
|
|
90
|
-
return
|
|
87
|
+
...earlyLink });
|
|
88
|
+
return file;
|
|
91
89
|
});
|
|
92
90
|
return reply.send({
|
|
93
91
|
name: torrent.name ?? "",
|
|
@@ -40,15 +40,15 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
40
40
|
? Number(req.query.fileLength) : null;
|
|
41
41
|
const linkMbps = Number(req.query.linkMbps);
|
|
42
42
|
const linkSampleAgeMs = Number(req.query.linkSampleAgeMs);
|
|
43
|
-
const waitElapsedMs = Number(req.query.waitElapsedMs);
|
|
44
43
|
const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
|
|
45
44
|
? { linkMbps, linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null }
|
|
46
45
|
: {};
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
const noteInitialSnapshot = () => {
|
|
47
|
+
if (consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)) {
|
|
48
|
+
viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey, fileIndex,
|
|
49
|
+
fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
52
|
|
|
53
53
|
let torrent;
|
|
54
54
|
try {
|
|
@@ -59,8 +59,8 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
59
59
|
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
60
60
|
}
|
|
61
61
|
if (!torrent) {
|
|
62
|
-
|
|
63
|
-
return reply.code(202).send({ pending: true
|
|
62
|
+
noteInitialSnapshot();
|
|
63
|
+
return reply.code(202).send({ pending: true });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
@@ -74,8 +74,8 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
74
74
|
logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
|
|
75
75
|
}
|
|
76
76
|
if (stats?.pending || !stats) {
|
|
77
|
-
|
|
78
|
-
return reply.code(202).send({ pending: true
|
|
77
|
+
noteInitialSnapshot();
|
|
78
|
+
return reply.code(202).send({ pending: true });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
@@ -149,10 +149,10 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
149
149
|
`file=${filePct} header=${header}${detail}`
|
|
150
150
|
);
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
if (consumerId && Number.isSafeInteger(generation)) {
|
|
153
|
+
viewerRequests?.noteWaitSourceSnapshot?.({ consumerId, generation, sourceKey, fileIndex,
|
|
154
154
|
fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
|
|
155
|
-
stats, ...earlyLink
|
|
156
|
-
|
|
157
|
-
return reply.send(
|
|
155
|
+
stats, ...earlyLink });
|
|
156
|
+
}
|
|
157
|
+
return reply.send(stats);
|
|
158
158
|
}
|
|
@@ -91,8 +91,8 @@ export class ViewerRequests {
|
|
|
91
91
|
#host;
|
|
92
92
|
/** @type {WeakMap<object, Map<string, object>>} */
|
|
93
93
|
#rateStateBySession = new WeakMap();
|
|
94
|
-
/** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number,
|
|
95
|
-
#
|
|
94
|
+
/** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, observedWaits: Set<number> }>} */
|
|
95
|
+
#waitObservations = new Map();
|
|
96
96
|
/**
|
|
97
97
|
* What the start forecasts cost, said once a minute: how many there were,
|
|
98
98
|
* their time on the main thread, and for each track whether the times of its
|
|
@@ -130,17 +130,10 @@ export class ViewerRequests {
|
|
|
130
130
|
this.#host = host;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
* a file, so the original action need not wait for a torrent-worker round trip.
|
|
136
|
-
*
|
|
137
|
-
* @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number, fileName: string, fileLength: number }} params
|
|
138
|
-
* @returns {object | null}
|
|
139
|
-
*/
|
|
140
|
-
waitEstimateForFile({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs,
|
|
141
|
-
waitElapsedMs }) {
|
|
133
|
+
/** Record the facts known while listing a source, without producing an ETA. */
|
|
134
|
+
noteWaitFileSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength, linkMbps, linkSampleAgeMs }) {
|
|
142
135
|
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
|
|
143
|
-
!Number.isSafeInteger(fileIndex) || fileIndex < 0) return
|
|
136
|
+
!Number.isSafeInteger(fileIndex) || fileIndex < 0) return;
|
|
144
137
|
const waitId = `${sourceKey}:${fileIndex}:${generation}`;
|
|
145
138
|
const key = `${consumerId}:${waitId}`;
|
|
146
139
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
@@ -156,48 +149,23 @@ export class ViewerRequests {
|
|
|
156
149
|
headerRemaining: null,
|
|
157
150
|
peers: null
|
|
158
151
|
};
|
|
159
|
-
const current = this.#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
164
|
-
if (changed) context.revision += 1;
|
|
165
|
-
this.#pruneEarlyWaits();
|
|
166
|
-
const elapsedMs = waitElapsedSince(context, waitElapsedMs);
|
|
167
|
-
const totalSeconds = context.activeTotalSeconds;
|
|
168
|
-
const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
|
|
169
|
-
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
170
|
-
this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds,
|
|
171
|
-
firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds });
|
|
172
|
-
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
173
|
-
}
|
|
174
|
-
const sameWait = current?.waitId === waitId;
|
|
175
|
-
const revision = sameWait ? current.revision : 0;
|
|
176
|
-
const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
|
|
177
|
-
waitElapsedMs });
|
|
178
|
-
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
179
|
-
if (sameWait && changed) context.revision += 1;
|
|
180
|
-
this.#pruneEarlyWaits();
|
|
181
|
-
const elapsedMs = waitElapsedSince(context, waitElapsedMs);
|
|
182
|
-
const totalSeconds = context.activeTotalSeconds;
|
|
183
|
-
const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
|
|
184
|
-
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
185
|
-
this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds,
|
|
186
|
-
firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds,
|
|
187
|
-
firstRequiredStages: context.activeFirstRequiredStages });
|
|
188
|
-
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
152
|
+
const current = this.#waitObservations.get(key);
|
|
153
|
+
const context = this.#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current });
|
|
154
|
+
this.#recordWaitSnapshot(context);
|
|
155
|
+
this.#pruneWaitObservations();
|
|
189
156
|
}
|
|
190
157
|
|
|
191
158
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
159
|
+
* Record current source and link facts for later forecast evaluation. These
|
|
160
|
+
* partial inputs do not determine the first presented frame, so this method
|
|
161
|
+
* does not produce a wait estimate.
|
|
194
162
|
*
|
|
195
163
|
* @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number | null, fileName?: string, fileLength?: number | null, stats?: object }} params
|
|
196
164
|
* @returns {object | null}
|
|
197
165
|
*/
|
|
198
|
-
|
|
199
|
-
stats = {}, linkMbps, linkSampleAgeMs,
|
|
200
|
-
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return
|
|
166
|
+
noteWaitSourceSnapshot({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
|
|
167
|
+
stats = {}, linkMbps, linkSampleAgeMs, processing = {} }) {
|
|
168
|
+
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return;
|
|
201
169
|
const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
|
|
202
170
|
const key = `${consumerId}:${waitId}`;
|
|
203
171
|
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
@@ -220,68 +188,10 @@ export class ViewerRequests {
|
|
|
220
188
|
videoSpeedX: processing.videoSpeedX ?? null,
|
|
221
189
|
audioSpeedX: processing.audioSpeedX ?? null
|
|
222
190
|
};
|
|
223
|
-
const current = this.#
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
sourceObserved: true, waitElapsedMs });
|
|
228
|
-
const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
229
|
-
const elapsedMs = waitElapsedSince(context, waitElapsedMs);
|
|
230
|
-
// The wait ends when the start forecast lets the picture start, and the
|
|
231
|
-
// first required pieces being there is only part of that: a source
|
|
232
|
-
// supplied slower than the film plays is held until the cushion carries
|
|
233
|
-
// it to the end. Counted to the first pieces alone, the estimate reached
|
|
234
|
-
// zero with seven minutes still to wait, and the page had no time to show
|
|
235
|
-
// (Home Assistant 2026-10-26, Despicable Me 3 BDRemux at 0.53x,
|
|
236
|
-
// torrent-tv/meta#239).
|
|
237
|
-
const firstRequiredSeconds = processing.firstRequiredSegmentsSeconds;
|
|
238
|
-
const startGateSeconds = processing.startGateSeconds;
|
|
239
|
-
const measuredWorkSeconds = Number.isFinite(startGateSeconds) && startGateSeconds >= 0
|
|
240
|
-
? Math.max(startGateSeconds, Number.isFinite(firstRequiredSeconds) ? firstRequiredSeconds : 0)
|
|
241
|
-
: firstRequiredSeconds;
|
|
242
|
-
const hasActiveEstimate = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0;
|
|
243
|
-
const stagesChanged = Array.isArray(processing.firstRequiredStages) &&
|
|
244
|
-
JSON.stringify(processing.firstRequiredStages) !== JSON.stringify(context.activeFirstRequiredStages);
|
|
245
|
-
const activeTotalSeconds = hasActiveEstimate
|
|
246
|
-
? elapsedMs / 1000 + measuredWorkSeconds
|
|
247
|
-
: context.activeTotalSeconds;
|
|
248
|
-
const activeEstimateChanged = hasActiveEstimate &&
|
|
249
|
-
(activeTotalSeconds !== context.activeTotalSeconds || stagesChanged);
|
|
250
|
-
if (hasActiveEstimate) {
|
|
251
|
-
context.activeTotalSeconds = activeTotalSeconds;
|
|
252
|
-
context.activeFirstRequiredSegmentsSeconds = measuredWorkSeconds;
|
|
253
|
-
if (Array.isArray(processing.firstRequiredStages)) {
|
|
254
|
-
context.activeFirstRequiredStages = processing.firstRequiredStages;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
const changed = featuresChanged || activeEstimateChanged;
|
|
258
|
-
if (sameWait && changed) context.revision += 1;
|
|
259
|
-
this.#pruneEarlyWaits();
|
|
260
|
-
const totalSeconds = activeTotalSeconds;
|
|
261
|
-
const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
|
|
262
|
-
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
263
|
-
this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds,
|
|
264
|
-
firstRequiredSegmentsSeconds: hasActiveEstimate ? firstRequiredSeconds ?? null : context.activeFirstRequiredSegmentsSeconds,
|
|
265
|
-
startGateSeconds: hasActiveEstimate && Number.isFinite(startGateSeconds) ? startGateSeconds : null,
|
|
266
|
-
firstRequiredStages: context.activeFirstRequiredStages });
|
|
267
|
-
return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, firstRequiredSegmentsSeconds,
|
|
271
|
-
startGateSeconds = null, firstRequiredStages }) {
|
|
272
|
-
if (context.loggedForecastRevision === context.revision) return;
|
|
273
|
-
const gated = Number.isFinite(startGateSeconds) &&
|
|
274
|
-
!(Number.isFinite(firstRequiredSegmentsSeconds) && firstRequiredSegmentsSeconds >= startGateSeconds);
|
|
275
|
-
const basis = gated ? "start-gate" : "first-required";
|
|
276
|
-
const estimate = { firstRequiredSegmentsSeconds, ...(Number.isFinite(startGateSeconds) ? { startGateSeconds } : {}),
|
|
277
|
-
...(Array.isArray(firstRequiredStages) ? { firstRequiredStages } : {}) };
|
|
278
|
-
logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
|
|
279
|
-
generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
|
|
280
|
-
predicted: { remainingSeconds: seconds, totalSeconds, basis, ...estimate } })}`);
|
|
281
|
-
this.#host.observeWait?.(context.sourceKey, "process", { process: "P15", phase: "event",
|
|
282
|
-
waitId: context.waitId, consumerId: context.consumerId, generation: context.generation,
|
|
283
|
-
elapsedMs, revision: context.revision, remainingSeconds: seconds, totalSeconds, basis, ...estimate });
|
|
284
|
-
context.loggedForecastRevision = context.revision;
|
|
191
|
+
const current = this.#waitObservations.get(key);
|
|
192
|
+
const context = this.#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current });
|
|
193
|
+
this.#recordWaitSnapshot(context);
|
|
194
|
+
this.#pruneWaitObservations();
|
|
285
195
|
}
|
|
286
196
|
|
|
287
197
|
/**
|
|
@@ -296,20 +206,19 @@ export class ViewerRequests {
|
|
|
296
206
|
*/
|
|
297
207
|
noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs, stages = null }) {
|
|
298
208
|
const key = `${consumerId}:${waitId}`;
|
|
299
|
-
const
|
|
300
|
-
if (!
|
|
209
|
+
const observation = this.#waitObservations.get(key);
|
|
210
|
+
if (!observation || observation.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || observation.observedWaits.has(wait) ||
|
|
301
211
|
!Number.isFinite(elapsedMs) || elapsedMs < 0) return;
|
|
302
|
-
const initialFeatures =
|
|
212
|
+
const initialFeatures = observation.snapshots[0];
|
|
303
213
|
if (initialFeatures) {
|
|
304
214
|
this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
|
|
305
|
-
featureSnapshots:
|
|
215
|
+
featureSnapshots: observation.snapshots.slice(1), stages });
|
|
306
216
|
}
|
|
307
|
-
|
|
308
|
-
|
|
217
|
+
observation.observedWaits.add(wait);
|
|
218
|
+
observation.updatedAt = Date.now();
|
|
309
219
|
}
|
|
310
220
|
|
|
311
|
-
#
|
|
312
|
-
sourceObserved = false, waitElapsedMs }) {
|
|
221
|
+
#setWaitObservation({ consumerId, generation, sourceKey, fileIndex, waitId, features, current }) {
|
|
313
222
|
const sameWait = current?.waitId === waitId;
|
|
314
223
|
const context = sameWait ? current : {
|
|
315
224
|
consumerId,
|
|
@@ -318,26 +227,21 @@ export class ViewerRequests {
|
|
|
318
227
|
fileIndex,
|
|
319
228
|
waitId,
|
|
320
229
|
snapshots: [],
|
|
321
|
-
hasSourceObservation: false,
|
|
322
230
|
observedWaits: new Set()
|
|
323
231
|
};
|
|
324
232
|
const measuredAt = performance.now();
|
|
325
|
-
const actionElapsedMs = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? waitElapsedMs : null;
|
|
326
|
-
const startedAt = actionElapsedMs === null
|
|
327
|
-
? Number.isFinite(context.startedAt) ? context.startedAt : measuredAt
|
|
328
|
-
: measuredAt - actionElapsedMs;
|
|
329
233
|
const ageElapsedMs = Number.isFinite(context.featuresMeasuredAt)
|
|
330
234
|
? Math.max(0, measuredAt - context.featuresMeasuredAt)
|
|
331
235
|
: 0;
|
|
332
236
|
const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
|
|
333
237
|
Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
|
|
334
|
-
featuresMeasuredAt: measuredAt,
|
|
335
|
-
|
|
336
|
-
this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
|
|
238
|
+
featuresMeasuredAt: measuredAt, updatedAt: Date.now() });
|
|
239
|
+
this.#waitObservations.set(`${consumerId}:${waitId}`, context);
|
|
337
240
|
return context;
|
|
338
241
|
}
|
|
339
242
|
|
|
340
|
-
#
|
|
243
|
+
#recordWaitSnapshot(context) {
|
|
244
|
+
const features = context.features;
|
|
341
245
|
const previous = context.snapshots.at(-1);
|
|
342
246
|
if (previous && JSON.stringify(previous) === JSON.stringify(features)) return false;
|
|
343
247
|
const snapshot = { ...features };
|
|
@@ -373,12 +277,12 @@ export class ViewerRequests {
|
|
|
373
277
|
return bytes;
|
|
374
278
|
}
|
|
375
279
|
|
|
376
|
-
#
|
|
280
|
+
#pruneWaitObservations() {
|
|
377
281
|
const cutoff = Date.now() - 30 * 60_000;
|
|
378
|
-
for (const [key, value] of this.#
|
|
379
|
-
if (value.updatedAt < cutoff) this.#
|
|
282
|
+
for (const [key, value] of this.#waitObservations) {
|
|
283
|
+
if (value.updatedAt < cutoff) this.#waitObservations.delete(key);
|
|
380
284
|
}
|
|
381
|
-
while (this.#
|
|
285
|
+
while (this.#waitObservations.size > 1000) this.#waitObservations.delete(this.#waitObservations.keys().next().value);
|
|
382
286
|
}
|
|
383
287
|
|
|
384
288
|
/**
|
|
@@ -674,13 +578,12 @@ export class ViewerRequests {
|
|
|
674
578
|
videoSpeedX: null,
|
|
675
579
|
audioSpeedX: null
|
|
676
580
|
};
|
|
677
|
-
// A seek is a new
|
|
678
|
-
// generation
|
|
679
|
-
|
|
680
|
-
this.#
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
this.#recordEarlyWaitSnapshot(context, features);
|
|
581
|
+
// A seek is a new observation group even when a caller repeats the same
|
|
582
|
+
// generation; its source facts must not join observations from the prior wait.
|
|
583
|
+
this.#waitObservations.delete(key);
|
|
584
|
+
const context = this.#setWaitObservation({ consumerId, generation, sourceKey: file.sourceKey,
|
|
585
|
+
fileIndex: file.fileIndex, waitId, features, current: null });
|
|
586
|
+
this.#recordWaitSnapshot(context);
|
|
684
587
|
}
|
|
685
588
|
|
|
686
589
|
/**
|
|
@@ -1109,11 +1012,11 @@ export class ViewerRequests {
|
|
|
1109
1012
|
? this.#host.generationOfRequest(consumerId) : null;
|
|
1110
1013
|
const earlyWaitId = Number.isSafeInteger(generation)
|
|
1111
1014
|
? `${session.file.sourceKey}:${session.file.fileIndex}:${generation}` : null;
|
|
1112
|
-
if (earlyWaitId && this.#
|
|
1015
|
+
if (earlyWaitId && this.#waitObservations.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
|
|
1113
1016
|
const linkMeasuredAt = Number(viewerReading?.linkMeasuredAt);
|
|
1114
1017
|
const videoOperation = forecast.operations.find(operation => operation.track === "video");
|
|
1115
1018
|
const audioOperation = forecast.operations.find(operation => operation.track === "audio");
|
|
1116
|
-
|
|
1019
|
+
this.noteWaitSourceSnapshot({ consumerId, generation,
|
|
1117
1020
|
sourceKey: session.file.sourceKey, fileIndex: session.file.fileIndex,
|
|
1118
1021
|
fileName: session.file.relativePath ?? session.file.name ?? "",
|
|
1119
1022
|
fileLength: primarySourceStats.fileLength, stats: primarySourceStats,
|
|
@@ -1125,14 +1028,12 @@ export class ViewerRequests {
|
|
|
1125
1028
|
videoSpeedX: videoOperation?.speed,
|
|
1126
1029
|
audioSpeedX: audioOperation?.speed,
|
|
1127
1030
|
firstRequiredSegmentsSeconds: forecast.firstRequiredSegmentsSeconds,
|
|
1128
|
-
// When the start forecast lets the picture start: its delay while
|
|
1129
|
-
// it holds the start, and nothing to add once it does not.
|
|
1130
1031
|
...(forecast.ready !== true && Number.isFinite(forecast.delaySeconds)
|
|
1131
1032
|
? { startGateSeconds: forecast.delaySeconds } : {}),
|
|
1132
1033
|
firstRequiredStages: forecast.firstRequiredStages
|
|
1133
1034
|
} });
|
|
1134
|
-
if (waitEstimate) forecast.waitEstimate = waitEstimate;
|
|
1135
1035
|
}
|
|
1036
|
+
forecast.waitEstimate = null;
|
|
1136
1037
|
const unavailableInput = forecast.unavailableSource;
|
|
1137
1038
|
const previousInputState = measurement.forecastInputState ?? null;
|
|
1138
1039
|
const currentInputState = forecastInputState(tracks, unavailableInput);
|
|
@@ -1466,31 +1367,6 @@ function linkMeasurementOf(viewer, reportedMbps, reportedAgeMs) {
|
|
|
1466
1367
|
return { mbps, ageMs };
|
|
1467
1368
|
}
|
|
1468
1369
|
|
|
1469
|
-
/**
|
|
1470
|
-
* Convert a learned full wait into the time still expected after this action.
|
|
1471
|
-
*
|
|
1472
|
-
* @param {number | null} totalSeconds
|
|
1473
|
-
* @param {number} elapsedMs
|
|
1474
|
-
* @returns {number | null}
|
|
1475
|
-
*/
|
|
1476
|
-
function earlyWaitRemainingSeconds(totalSeconds, elapsedMs) {
|
|
1477
|
-
if (!Number.isFinite(totalSeconds) || totalSeconds < 0 || !Number.isFinite(elapsedMs) || elapsedMs < 0) return null;
|
|
1478
|
-
return Math.max(0, totalSeconds - elapsedMs / 1000);
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
/**
|
|
1482
|
-
* Prefer the browser's monotonic age of the original action; retain a proxy
|
|
1483
|
-
* monotonic clock fallback for older pages that do not send it.
|
|
1484
|
-
*
|
|
1485
|
-
* @param {object} context
|
|
1486
|
-
* @param {number | undefined} reportedElapsedMs
|
|
1487
|
-
* @returns {number}
|
|
1488
|
-
*/
|
|
1489
|
-
function waitElapsedSince(context, reportedElapsedMs) {
|
|
1490
|
-
if (Number.isFinite(reportedElapsedMs) && reportedElapsedMs >= 0) return reportedElapsedMs;
|
|
1491
|
-
return Number.isFinite(context.startedAt) ? Math.max(0, performance.now() - context.startedAt) : 0;
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
1370
|
/**
|
|
1495
1371
|
* The download service a source's forecast runs on: what the swarm carried
|
|
1496
1372
|
* while our selection held none of its connections back, but never less than
|
|
@@ -521,7 +521,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
521
521
|
}));
|
|
522
522
|
const sourceByteCoverage = new Map([...sourceByteService].map(([id, ranges]) => [id, numericCoverage(ranges)]));
|
|
523
523
|
const sourceArrivals = new Map([...sourceByteService].map(([id, ranges]) => [id, indexedArrivals(ranges)]));
|
|
524
|
-
|
|
524
|
+
const transferJobs = [];
|
|
525
525
|
const completions = [];
|
|
526
526
|
const productions = [];
|
|
527
527
|
for (const job of jobs) {
|
|
@@ -609,9 +609,17 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
609
609
|
const sourcePlanned = sourceArrivalAt > 0 && plan.finish >= sourceArrivalAt;
|
|
610
610
|
productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0, sourceArrivalAt,
|
|
611
611
|
sourcePlanned, runAdmissionAt, runOpenSeconds, productionStartAt, encodeWork });
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
612
|
+
transferJobs.push({ trackIndex, segment, sizeBits, readyAt: producedAt, sourceArrivalAt, sourcePlanned,
|
|
613
|
+
order: transferJobs.length, appendSeconds: finiteNonNegative(track.appendSeconds) });
|
|
614
|
+
}
|
|
615
|
+
// A transfer waits only for work that is ready on its own stream and for
|
|
616
|
+
// bytes already being served by the shared link. A video piece whose encode
|
|
617
|
+
// is still running cannot reserve link time ahead of a ready audio piece.
|
|
618
|
+
// Each stream remains ordered; the single link remains shared.
|
|
619
|
+
const transfers = scheduleTransfers(transferJobs, link);
|
|
620
|
+
const transferFinish = transfers.reduce((finish, transfer) => Math.max(finish, transfer.deliveryAt), 0);
|
|
621
|
+
for (const transfer of transfers) {
|
|
622
|
+
const { trackIndex, segment, deliveryAt, appendSeconds, sourceArrivalAt, sourcePlanned } = transfer;
|
|
615
623
|
completions.push({ at: deliveryAt + appendSeconds, deliveryAt, appendSeconds, trackIndex, segment, sourceArrivalAt,
|
|
616
624
|
sourcePlanned });
|
|
617
625
|
}
|
|
@@ -780,6 +788,48 @@ function isPiece(segment) {
|
|
|
780
788
|
Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
|
|
781
789
|
}
|
|
782
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Serve each stream's fragments in order on one shared link, starting the
|
|
793
|
+
* earliest released fragment whenever the link becomes free.
|
|
794
|
+
*
|
|
795
|
+
* @param {Array<{ trackIndex: number, segment: object, sizeBits: number, readyAt: number,
|
|
796
|
+
* order: number, appendSeconds: number }>} jobs
|
|
797
|
+
* @param {{ finish: (bits: number, startAt: number) => number }} link
|
|
798
|
+
*/
|
|
799
|
+
function scheduleTransfers(jobs, link) {
|
|
800
|
+
const queues = new Map();
|
|
801
|
+
for (const job of jobs) {
|
|
802
|
+
const queue = queues.get(job.trackIndex) ?? [];
|
|
803
|
+
queue.push(job);
|
|
804
|
+
queues.set(job.trackIndex, queue);
|
|
805
|
+
}
|
|
806
|
+
const served = new Map();
|
|
807
|
+
const trackFreeAt = new Map();
|
|
808
|
+
const transfers = [];
|
|
809
|
+
let linkFreeAt = 0;
|
|
810
|
+
while (transfers.length < jobs.length) {
|
|
811
|
+
let selected = null;
|
|
812
|
+
let selectedReadyAt = Number.POSITIVE_INFINITY;
|
|
813
|
+
for (const [trackIndex, queue] of queues) {
|
|
814
|
+
const nextIndex = served.get(trackIndex) ?? 0;
|
|
815
|
+
const job = queue[nextIndex];
|
|
816
|
+
if (!job) continue;
|
|
817
|
+
const readyAt = Math.max(job.readyAt, trackFreeAt.get(trackIndex) ?? 0);
|
|
818
|
+
if (readyAt < selectedReadyAt || readyAt === selectedReadyAt && (selected === null || job.order < selected.order)) {
|
|
819
|
+
selected = job;
|
|
820
|
+
selectedReadyAt = readyAt;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (!selected) break;
|
|
824
|
+
const deliveryAt = link.finish(selected.sizeBits, Math.max(linkFreeAt, selectedReadyAt));
|
|
825
|
+
transfers.push({ ...selected, deliveryAt });
|
|
826
|
+
served.set(selected.trackIndex, (served.get(selected.trackIndex) ?? 0) + 1);
|
|
827
|
+
trackFreeAt.set(selected.trackIndex, deliveryAt);
|
|
828
|
+
linkFreeAt = deliveryAt;
|
|
829
|
+
}
|
|
830
|
+
return transfers;
|
|
831
|
+
}
|
|
832
|
+
|
|
783
833
|
/**
|
|
784
834
|
* A duration-level estimate of the suffix after the exact near window. Source
|
|
785
835
|
* work uses each file's aggregate byte density and measured download service;
|