@torrent-tv/proxy 2.101.29 → 2.101.30

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.101.29",
3
+ "version": "2.101.30",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -91,14 +91,19 @@ export async function handleApiClientLogsPost(req, reply, { clientLogs, viewerRe
91
91
  }
92
92
  for (const row of rows) {
93
93
  const message = row && typeof row.msg === "string" ? row.msg : "";
94
- const match = /^\[first-frame-observation\] consumer=([^ ]+) generation=(\d+) waitId=([^ ]+) wait=(\d+) audioReady=(true|false) elapsedMs=([\d.]+)$/.exec(message);
94
+ const match = /^\[first-frame-observation\] consumer=([^ ]+) generation=(\d+) waitId=([^ ]+) wait=(\d+) audioReady=(true|false) elapsedMs=([\d.]+)(?: coldStartTotalMs=([\d.]+) transportMs=([\d.]+) planMs=([\d.]+) prepareMs=([\d.]+) prebufferMs=([\d.]+) presentationMs=([\d.]+))?$/.exec(message);
95
95
  if (match) {
96
96
  const elapsedMs = Number(match[6]);
97
97
  if (match[1].length <= 64 && Number.isSafeInteger(Number(match[2])) && match[3].length <= 256 &&
98
98
  Number.isSafeInteger(Number(match[4])) && match[5] === "true" &&
99
99
  Number.isFinite(elapsedMs) && elapsedMs >= 0) {
100
+ const stageValues = match.slice(7).map(value => value === undefined ? null : Number(value));
101
+ const stages = stageValues.every(value => value !== null && Number.isFinite(value) && value >= 0)
102
+ ? { totalMs: stageValues[0], transportMs: stageValues[1], planMs: stageValues[2],
103
+ prepareMs: stageValues[3], prebufferMs: stageValues[4], presentationMs: stageValues[5] }
104
+ : null;
100
105
  viewerRequests?.noteFirstFrameObservation?.({ consumerId: match[1], generation: Number(match[2]),
101
- waitId: match[3], wait: Number(match[4]), elapsedMs });
106
+ waitId: match[3], wait: Number(match[4]), elapsedMs, stages });
102
107
  }
103
108
  }
104
109
  }
@@ -6,7 +6,7 @@
6
6
  * NOT A TABLE OF QUALITY, and not a condition for anything. The proxy selects,
7
7
  * admits and serves with nothing kept here at all: the startup calibration is
8
8
  * what qualifies a mode and prices it, and the admission is what decides
9
- * whether there is room. What is kept here makes three of those figures more
9
+ * whether there is room. What is kept here makes four of those figures more
10
10
  * exact on THIS machine once it has done the work for real:
11
11
  *
12
12
  * 1. how fast an encode of this mode, at this size, actually ran alone on this
@@ -19,6 +19,9 @@
19
19
  * 3. how long an output of this mode took to have the piece a viewer needed
20
20
  * after it was asked for, and what that viewer held when they moved — used
21
21
  * as the time to ready when deciding whether a draining buffer is a threat.
22
+ * 4. the measured transport, track-plan, output-preparation, prebuffer and
23
+ * first-presented-frame stages for an earlier start on this proxy
24
+ * configuration, used as separate inputs to an early wait forecast.
22
25
  *
23
26
  * WHAT IS NEVER KEPT: a picture, a clip, a segment, anything about a viewer
24
27
  * beyond the seconds they held, and anything that leaves this machine.
@@ -265,31 +268,21 @@ export class LocalObservations {
265
268
  }
266
269
 
267
270
  /**
268
- * Keep a browser-measured wait outcome with the source and link readings
269
- * visible before the container was inspected. These readings are useful
270
- * only on the encoder configurations present on this host.
271
+ * The viewer presented its first audio-ready frame: keep the separately
272
+ * measured stage timings and the input snapshots from the start of that run.
271
273
  *
272
- * @param {{ seconds: number, features: object }} params
274
+ * @param {{ seconds: number, features: object, featureSnapshots?: object[], stages?: object | null }} params
273
275
  * @returns {void}
274
276
  */
275
- noteFirstFrame({ seconds, features }) {
277
+ noteFirstFrame({ seconds, features, featureSnapshots = null, stages = null }) {
276
278
  if (!Number.isFinite(seconds) || seconds < 0 || !features || typeof features !== "object") return;
277
279
  const key = this.#firstFrameKey();
280
+ const snapshots = [features, ...(Array.isArray(featureSnapshots) ? featureSnapshots : [])]
281
+ .filter(value => value && typeof value === "object").slice(0, 2).map(firstFrameFeatures);
278
282
  const readings = [...(this.#entries.firstFrame[key] ?? []), {
279
- seconds,
280
- extension: typeof features.extension === "string" ? features.extension : "",
281
- fileLength: positive(features.fileLength),
282
- downloadSpeed: nonNegative(features.downloadSpeed),
283
- linkMbps: nonNegative(features.linkMbps),
284
- linkSampleAgeMs: nonNegative(features.linkSampleAgeMs),
285
- preparedOutputBytes: nonNegative(features.preparedOutputBytes),
286
- fileAvailable: typeof features.fileAvailable === "boolean" ? features.fileAvailable : null,
287
- headerRemaining: nonNegative(features.headerRemaining),
288
- peers: nonNegative(features.peers),
289
- videoOperation: operationOf(features.videoOperation),
290
- audioOperation: operationOf(features.audioOperation),
291
- videoSpeedX: nonNegative(features.videoSpeedX),
292
- audioSpeedX: nonNegative(features.audioSpeedX),
283
+ ...snapshots[0],
284
+ featureSnapshots: snapshots,
285
+ stages: stagesOf(stages),
293
286
  at: this.#now()
294
287
  }].slice(-READINGS_KEPT);
295
288
  this.#entries.firstFrame[key] = readings;
@@ -297,62 +290,86 @@ export class LocalObservations {
297
290
  }
298
291
 
299
292
  /**
300
- * Predict this host's first-frame wait from the closest measured start with
301
- * the same media suffix. Distance uses squared log-ratios for numeric
302
- * inputs and exact-state distance for categorical inputs; it does not mix
303
- * bytes, bit rates, processing speed and peers in their raw units. Missing
304
- * data contributes distance only when the other observation measured it.
293
+ * Predict the first-frame wait as the sum of separately predicted cold-start
294
+ * stages. Each stage uses all compatible measurements, weighted by the
295
+ * normalized distance between the measured inputs; no complete past wait is
296
+ * returned as the forecast. A known cache/source state must also be known in
297
+ * the training observation, otherwise that observation is not comparable.
305
298
  *
306
299
  * @param {object} features
307
300
  * @returns {number | null}
308
301
  */
309
302
  firstFrameSeconds(features) {
310
303
  if (!features || typeof features !== "object") return null;
311
- const readings = this.#entries.firstFrame[this.#firstFrameKey()] ?? [];
312
- const candidates = readings.filter(reading => reading.extension === (features.extension ?? ""));
304
+ const candidates = (this.#entries.firstFrame[this.#firstFrameKey()] ?? [])
305
+ .filter(reading => reading.extension === (features.extension ?? "") && reading.stages);
313
306
  if (candidates.length === 0) return null;
314
307
  const dimensions = ["fileLength", "downloadSpeed", "linkMbps", "linkSampleAgeMs", "preparedOutputBytes",
315
308
  "fileAvailable", "headerRemaining", "peers", "videoOperation", "audioOperation", "videoSpeedX", "audioSpeedX"];
316
- const scales = new Map(dimensions.map(key => [key, median(candidates
317
- .map(reading => FIRST_FRAME_CATEGORICAL.has(key) ? null : nonNegative(reading[key]))
309
+ const pointsOf = reading => Array.isArray(reading.featureSnapshots) && reading.featureSnapshots.length > 0
310
+ ? reading.featureSnapshots : [reading];
311
+ const scales = new Map(dimensions.map(key => [key, median(candidates.flatMap(reading => pointsOf(reading)
312
+ .map(point => FIRST_FRAME_CATEGORICAL.has(key) ? null : nonNegative(point[key])))
318
313
  .filter(value => value !== null && value > 0))]));
319
- let best = null;
314
+ const distances = [];
320
315
  for (const reading of candidates) {
321
- let distance = 0;
322
- let used = 0;
323
- let shared = 0;
324
- for (const key of dimensions) {
325
- const current = FIRST_FRAME_CATEGORICAL.has(key)
326
- ? key === "fileAvailable" ? (typeof features[key] === "boolean" ? features[key] : null)
327
- : operationOf(features[key])
328
- : nonNegative(features[key]);
329
- const previous = FIRST_FRAME_CATEGORICAL.has(key)
330
- ? key === "fileAvailable" ? (typeof reading[key] === "boolean" ? reading[key] : null)
331
- : operationOf(reading[key])
332
- : nonNegative(reading[key]);
333
- if (current === null && previous === null) continue;
334
- if (current === null || previous === null) {
335
- distance += 1;
316
+ let bestDistance = null;
317
+ for (const point of pointsOf(reading)) {
318
+ if ((features.preparedOutputBytes !== null && features.preparedOutputBytes !== undefined &&
319
+ point.preparedOutputBytes === null) ||
320
+ [...FIRST_FRAME_CATEGORICAL].some(key => {
321
+ const current = key === "fileAvailable" ? features[key] : operationOf(features[key]);
322
+ return current !== null && current !== undefined && point[key] !== current;
323
+ })) continue;
324
+ let distance = 0;
325
+ let used = 0;
326
+ let shared = 0;
327
+ for (const key of dimensions) {
328
+ const current = FIRST_FRAME_CATEGORICAL.has(key)
329
+ ? key === "fileAvailable" ? (typeof features[key] === "boolean" ? features[key] : null)
330
+ : operationOf(features[key])
331
+ : nonNegative(features[key]);
332
+ const previous = FIRST_FRAME_CATEGORICAL.has(key)
333
+ ? key === "fileAvailable" ? (typeof point[key] === "boolean" ? point[key] : null)
334
+ : operationOf(point[key])
335
+ : nonNegative(point[key]);
336
+ if (current === null && previous === null) continue;
337
+ if (current === null || previous === null) {
338
+ distance += 1;
339
+ used += 1;
340
+ continue;
341
+ }
342
+ shared += 1;
343
+ const scale = scales.get(key);
344
+ const difference = typeof current !== "number" && typeof previous !== "number"
345
+ ? Number(current !== previous)
346
+ : scale === null
347
+ ? Number(current !== previous)
348
+ : Math.log1p(current / scale) - Math.log1p(previous / scale);
349
+ distance += difference * difference;
336
350
  used += 1;
337
- continue;
338
351
  }
339
- shared += 1;
340
- const scale = scales.get(key);
341
- const difference = typeof current !== "number" && typeof previous !== "number"
342
- ? Number(current !== previous)
343
- : scale === null
344
- ? Number(current !== previous)
345
- : Math.log1p(current / scale) - Math.log1p(previous / scale);
346
- distance += difference * difference;
347
- used += 1;
348
- }
349
- if (used === 0 || shared === 0) continue;
350
- distance /= used;
351
- if (!best || distance < best.distance || (distance === best.distance && reading.at > best.reading.at)) {
352
- best = { distance, reading };
352
+ if (used === 0 || shared === 0) continue;
353
+ distance /= used;
354
+ if (bestDistance === null || distance < bestDistance) bestDistance = distance;
353
355
  }
356
+ if (bestDistance !== null) distances.push({ distance: bestDistance, reading });
354
357
  }
355
- return best?.reading.seconds ?? null;
358
+ if (distances.length === 0) return null;
359
+ const exact = distances.filter(candidate => candidate.distance === 0);
360
+ const weighted = exact.length > 0
361
+ ? exact.map(candidate => ({ ...candidate, weight: 1 }))
362
+ : distances.map(candidate => ({ ...candidate, weight: 1 / candidate.distance }));
363
+ const stages = ["transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
364
+ const predicted = stages.map(stage => {
365
+ const applicable = weighted.filter(candidate => Number.isFinite(candidate.reading.stages[stage]));
366
+ const totalWeight = applicable.reduce((sum, candidate) => sum + candidate.weight, 0);
367
+ return totalWeight > 0
368
+ ? applicable.reduce((sum, candidate) => sum + candidate.reading.stages[stage] * candidate.weight, 0) / totalWeight
369
+ : null;
370
+ });
371
+ if (predicted.some(value => value === null)) return null;
372
+ return predicted.reduce((sum, value) => sum + value, 0) / 1000;
356
373
  }
357
374
 
358
375
  #firstFrameKey() {
@@ -470,6 +487,33 @@ function operationOf(value) {
470
487
  return value === "copy" || value === "encode" ? value : null;
471
488
  }
472
489
 
490
+ /** @param {unknown} value @returns {{ totalMs: number, transportMs: number, planMs: number, prepareMs: number, prebufferMs: number, presentationMs: number } | null} */
491
+ function stagesOf(value) {
492
+ if (!value || typeof value !== "object") return null;
493
+ const keys = ["totalMs", "transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
494
+ const stages = Object.fromEntries(keys.map(key => [key, nonNegative(value[key])]));
495
+ return keys.every(key => stages[key] !== null) ? stages : null;
496
+ }
497
+
498
+ /** @param {object} features @returns {object} */
499
+ function firstFrameFeatures(features) {
500
+ return {
501
+ extension: typeof features.extension === "string" ? features.extension : "",
502
+ fileLength: positive(features.fileLength),
503
+ downloadSpeed: nonNegative(features.downloadSpeed),
504
+ linkMbps: nonNegative(features.linkMbps),
505
+ linkSampleAgeMs: nonNegative(features.linkSampleAgeMs),
506
+ preparedOutputBytes: nonNegative(features.preparedOutputBytes),
507
+ fileAvailable: typeof features.fileAvailable === "boolean" ? features.fileAvailable : null,
508
+ headerRemaining: nonNegative(features.headerRemaining),
509
+ peers: nonNegative(features.peers),
510
+ videoOperation: operationOf(features.videoOperation),
511
+ audioOperation: operationOf(features.audioOperation),
512
+ videoSpeedX: nonNegative(features.videoSpeedX),
513
+ audioSpeedX: nonNegative(features.audioSpeedX)
514
+ };
515
+ }
516
+
473
517
  /** @param {number[]} values @returns {number | null} */
474
518
  function median(values) {
475
519
  if (values.length === 0) return null;
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { logger } from "../../utils/logger.js";
11
- import { isOutputName } from "../encode/output/index.js";
11
+ import { isOutputName, OutputSpec } from "../encode/output/index.js";
12
12
  import { withSubtitleReadiness } from "./subtitle-readiness.js";
13
13
 
14
14
  function isWarmupTimeoutError(error) {
@@ -190,7 +190,10 @@ export class ViewerRequests {
190
190
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
191
191
  if (sameWait && changed) context.revision += 1;
192
192
  this.#pruneEarlyWaits();
193
- const totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
193
+ const measuredWorkSeconds = processing.firstRequiredSegmentsSeconds;
194
+ const totalSeconds = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0
195
+ ? waitElapsedSince(context, waitElapsedMs) / 1000 + measuredWorkSeconds
196
+ : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
194
197
  const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
195
198
  if (!Number.isFinite(seconds) || seconds < 0) return null;
196
199
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
@@ -201,16 +204,20 @@ export class ViewerRequests {
201
204
  * viewing. The saved source and link readings are the ones available at
202
205
  * selection time, before the full track plan existed.
203
206
  *
204
- * @param {{ consumerId: string, generation: number, waitId: string, wait: number, elapsedMs: number }} params
207
+ * @param {{ consumerId: string, generation: number, waitId: string, wait: number, elapsedMs: number,
208
+ * stages?: { totalMs: number, transportMs: number, planMs: number, prepareMs: number,
209
+ * prebufferMs: number, presentationMs: number } | null }} params
205
210
  * @returns {void}
206
211
  */
207
- noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs }) {
212
+ noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs, stages = null }) {
208
213
  const key = `${consumerId}:${waitId}`;
209
214
  const early = this.#earlyWaits.get(key);
210
215
  if (!early || early.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || early.observedWaits.has(wait) ||
211
216
  !Number.isFinite(elapsedMs) || elapsedMs < 0) return;
212
- for (const snapshot of early.snapshots) {
213
- this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: snapshot });
217
+ const initialFeatures = early.snapshots[0];
218
+ if (initialFeatures) {
219
+ this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: initialFeatures,
220
+ featureSnapshots: early.snapshots.slice(1), stages });
214
221
  }
215
222
  early.observedWaits.add(wait);
216
223
  early.updatedAt = Date.now();
@@ -259,11 +266,21 @@ export class ViewerRequests {
259
266
  if (!outputs?.values || !segmentStore?.sizesOf) return null;
260
267
  let bytes = 0;
261
268
  const seen = new Set();
262
- for (const output of outputs.values()) {
263
- if (output.file?.sourceKey !== sourceKey || output.file?.fileIndex !== fileIndex || !output.outputKey) continue;
264
- if (seen.has(output.outputKey)) continue;
265
- seen.add(output.outputKey);
266
- for (const size of segmentStore.sizesOf(output.outputKey).values()) {
269
+ const liveOutputs = [...outputs.values()];
270
+ const keys = new Set([
271
+ ...liveOutputs.map(output => output.outputKey),
272
+ ...(segmentStore.addresses?.() ?? [])
273
+ ].filter(key => typeof key === "string" && key.length > 0));
274
+ for (const outputKey of keys) {
275
+ const output = liveOutputs.find(candidate => candidate.outputKey === outputKey);
276
+ const spec = output ? null : OutputSpec.fromKey(outputKey);
277
+ const matchesFile = output
278
+ ? output.file?.sourceKey === sourceKey && output.file?.fileIndex === fileIndex
279
+ : spec?.sourceKey === sourceKey &&
280
+ (spec.video?.fileIndex === fileIndex || spec.audio?.fileIndex === fileIndex);
281
+ if (!matchesFile || seen.has(outputKey)) continue;
282
+ seen.add(outputKey);
283
+ for (const size of segmentStore.sizesOf(outputKey).values()) {
267
284
  if (Number.isFinite(size) && size > 0) bytes += size;
268
285
  }
269
286
  }
@@ -976,7 +993,8 @@ export class ViewerRequests {
976
993
  videoOperation: videoOperation?.operation,
977
994
  audioOperation: audioOperation?.operation,
978
995
  videoSpeedX: videoOperation?.speed,
979
- audioSpeedX: audioOperation?.speed
996
+ audioSpeedX: audioOperation?.speed,
997
+ firstRequiredSegmentsSeconds: forecast.firstRequiredSegmentsSeconds
980
998
  } });
981
999
  if (waitEstimate) forecast.waitEstimate = waitEstimate;
982
1000
  }
@@ -542,6 +542,15 @@ export function predictPlaybackReadiness(input = {}) {
542
542
  ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
543
543
  }
544
544
  }
545
+ const firstRequiredTrackSeconds = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
546
+ const first = state.segments.find(segment => segment.endSeconds > position);
547
+ if (!first) return 0;
548
+ if (first.ranges.every(range => contains(state.clientRanges, range.start, range.end))) return 0;
549
+ const completion = completions.find(item => item.trackIndex === trackIndex && item.segment.index === first.index);
550
+ return completion?.at ?? null;
551
+ });
552
+ const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
553
+ ? Math.max(0, ...firstRequiredTrackSeconds) : null;
545
554
  const delay = Math.max(0, ...constraints);
546
555
  const originReady = trackState.every(({ clientRanges }) => compare(contiguousEnd(clientRanges, at), at) > 0);
547
556
  const ready = delay === 0 && originReady;
@@ -549,6 +558,7 @@ export function predictPlaybackReadiness(input = {}) {
549
558
  Math.max(0, reserve - buffered), ready ? 'trajectory-safe-now' :
550
559
  Number.isFinite(delay) ? 'minimum-safe-delay' : 'service-not-advancing', preparedSegments);
551
560
  return { ...forecast, measuredAt: now, predictedStartAt: Number.isFinite(delay) ? now + delay * 1000 : null,
561
+ firstRequiredSegmentsSeconds,
552
562
  // The piece whose `kind` — its production, or its delivery to the page —
553
563
  // comes `atSeconds` from now while the media before it lasts
554
564
  // `aheadSeconds`: the difference is the delay.