@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.107.45",
3
+ "version": "2.107.47",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
- const waitEstimate = viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey,
85
+ viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey,
88
86
  fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length,
89
- ...earlyLink, ...earlyWaitAge }) ?? null;
90
- return waitEstimate ? { ...file, waitEstimate } : file;
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 earlyWaitAge = Number.isFinite(waitElapsedMs) && waitElapsedMs >= 0 ? { waitElapsedMs } : {};
48
- const initialEstimate = () => consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)
49
- ? viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey, fileIndex,
50
- fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink, ...earlyWaitAge }) ?? null
51
- : null;
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
- const waitEstimate = initialEstimate();
63
- return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
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
- const waitEstimate = initialEstimate();
78
- return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
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
- const waitEstimate = consumerId && Number.isSafeInteger(generation)
153
- ? viewerRequests?.waitEstimateForSource?.({ consumerId, generation, sourceKey, fileIndex,
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, ...earlyWaitAge }) ?? null
156
- : null;
157
- return reply.send(waitEstimate ? { ...stats, waitEstimate } : stats);
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, revision: number, observedWaits: Set<number> }>} */
95
- #earlyWaits = new Map();
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
- * Attach a host-matched estimate to the file list before the viewer chooses
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 null;
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.#earlyWaits.get(key);
160
- if (current?.waitId === waitId && current.hasSourceObservation) {
161
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features,
162
- revision: current.revision, current, waitElapsedMs });
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
- * Keep the measurements available before the container is inspected and
193
- * predict the complete first-frame wait from this host's earlier outcomes.
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
- waitEstimateForSource({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null,
199
- stats = {}, linkMbps, linkSampleAgeMs, waitElapsedMs, processing = {} }) {
200
- if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return null;
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.#earlyWaits.get(key);
224
- const sameWait = current?.waitId === waitId;
225
- const revision = sameWait ? current.revision : 0;
226
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
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 early = this.#earlyWaits.get(key);
300
- if (!early || early.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || early.observedWaits.has(wait) ||
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 = early.snapshots[0];
212
+ const initialFeatures = observation.snapshots[0];
303
213
  if (initialFeatures) {
304
214
  this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
305
- featureSnapshots: early.snapshots.slice(1), stages });
215
+ featureSnapshots: observation.snapshots.slice(1), stages });
306
216
  }
307
- early.observedWaits.add(wait);
308
- early.updatedAt = Date.now();
217
+ observation.observedWaits.add(wait);
218
+ observation.updatedAt = Date.now();
309
219
  }
310
220
 
311
- #setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
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, startedAt, updatedAt: Date.now(), revision,
335
- hasSourceObservation: context.hasSourceObservation || sourceObserved });
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
- #recordEarlyWaitSnapshot(context, features) {
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
- #pruneEarlyWaits() {
280
+ #pruneWaitObservations() {
377
281
  const cutoff = Date.now() - 30 * 60_000;
378
- for (const [key, value] of this.#earlyWaits) {
379
- if (value.updatedAt < cutoff) this.#earlyWaits.delete(key);
282
+ for (const [key, value] of this.#waitObservations) {
283
+ if (value.updatedAt < cutoff) this.#waitObservations.delete(key);
380
284
  }
381
- while (this.#earlyWaits.size > 1000) this.#earlyWaits.delete(this.#earlyWaits.keys().next().value);
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 waiting action even when a caller repeats the same
678
- // generation. Start its clock at the explicit seek request, before the
679
- // next readiness poll can return the first current-work estimate.
680
- this.#earlyWaits.delete(key);
681
- const context = this.#setEarlyWait({ consumerId, generation, sourceKey: file.sourceKey,
682
- fileIndex: file.fileIndex, waitId, features, revision: 0, current: null, waitElapsedMs: 0 });
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.#earlyWaits.has(`${consumerId}:${earlyWaitId}`) && primarySourceStats) {
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
- const waitEstimate = this.waitEstimateForSource({ consumerId, generation,
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
- let transferFinish = 0;
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
- const deliveryAt = link.finish(sizeBits, Math.max(transferFinish, producedAt));
613
- transferFinish = deliveryAt;
614
- const appendSeconds = finiteNonNegative(track.appendSeconds);
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;