@torrent-tv/proxy 2.101.29 → 2.101.31

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.31",
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,30 @@ 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
+ * Price the early wait from separate measured stage medians for the same
294
+ * output path. Numeric proximity of a past file, source rate, or link rate
295
+ * does not select a completed run. Each observation contributes once, using
296
+ * its latest snapshot that states the current cache and operation path.
305
297
  *
306
298
  * @param {object} features
307
299
  * @returns {number | null}
308
300
  */
309
301
  firstFrameSeconds(features) {
310
302
  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 ?? ""));
313
- if (candidates.length === 0) return null;
314
- const dimensions = ["fileLength", "downloadSpeed", "linkMbps", "linkSampleAgeMs", "preparedOutputBytes",
315
- "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]))
318
- .filter(value => value !== null && value > 0))]));
319
- let best = null;
320
- 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;
336
- used += 1;
337
- continue;
338
- }
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 };
353
- }
303
+ const stages = ["transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
304
+ const candidates = [];
305
+ for (const reading of this.#entries.firstFrame[this.#firstFrameKey()] ?? []) {
306
+ if (reading.extension !== (features.extension ?? "") || !reading.stages) continue;
307
+ const points = Array.isArray(reading.featureSnapshots) && reading.featureSnapshots.length > 0
308
+ ? reading.featureSnapshots : [reading];
309
+ const point = [...points].reverse().find(candidate => firstFramePathMatches(features, candidate));
310
+ if (point) candidates.push(reading.stages);
354
311
  }
355
- return best?.reading.seconds ?? null;
312
+ if (candidates.length === 0) return null;
313
+ const prices = stages.map(stage => median(candidates.map(reading => reading[stage])
314
+ .filter(Number.isFinite)));
315
+ if (prices.some(value => value === null)) return null;
316
+ return prices.reduce((sum, value) => sum + value, 0) / 1000;
356
317
  }
357
318
 
358
319
  #firstFrameKey() {
@@ -470,6 +431,47 @@ function operationOf(value) {
470
431
  return value === "copy" || value === "encode" ? value : null;
471
432
  }
472
433
 
434
+ /** @param {object} current @param {object} previous @returns {boolean} */
435
+ function firstFramePathMatches(current, previous) {
436
+ for (const key of FIRST_FRAME_CATEGORICAL) {
437
+ const expected = key === "fileAvailable" ? current[key] : operationOf(current[key]);
438
+ if (expected !== null && expected !== undefined && previous[key] !== expected) return false;
439
+ }
440
+ const preparedBytes = nonNegative(current.preparedOutputBytes);
441
+ if (preparedBytes !== null) {
442
+ const previousBytes = nonNegative(previous.preparedOutputBytes);
443
+ if (previousBytes === null || (previousBytes > 0) !== (preparedBytes > 0)) return false;
444
+ }
445
+ return true;
446
+ }
447
+
448
+ /** @param {unknown} value @returns {{ totalMs: number, transportMs: number, planMs: number, prepareMs: number, prebufferMs: number, presentationMs: number } | null} */
449
+ function stagesOf(value) {
450
+ if (!value || typeof value !== "object") return null;
451
+ const keys = ["totalMs", "transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
452
+ const stages = Object.fromEntries(keys.map(key => [key, nonNegative(value[key])]));
453
+ return keys.every(key => stages[key] !== null) ? stages : null;
454
+ }
455
+
456
+ /** @param {object} features @returns {object} */
457
+ function firstFrameFeatures(features) {
458
+ return {
459
+ extension: typeof features.extension === "string" ? features.extension : "",
460
+ fileLength: positive(features.fileLength),
461
+ downloadSpeed: nonNegative(features.downloadSpeed),
462
+ linkMbps: nonNegative(features.linkMbps),
463
+ linkSampleAgeMs: nonNegative(features.linkSampleAgeMs),
464
+ preparedOutputBytes: nonNegative(features.preparedOutputBytes),
465
+ fileAvailable: typeof features.fileAvailable === "boolean" ? features.fileAvailable : null,
466
+ headerRemaining: nonNegative(features.headerRemaining),
467
+ peers: nonNegative(features.peers),
468
+ videoOperation: operationOf(features.videoOperation),
469
+ audioOperation: operationOf(features.audioOperation),
470
+ videoSpeedX: nonNegative(features.videoSpeedX),
471
+ audioSpeedX: nonNegative(features.audioSpeedX)
472
+ };
473
+ }
474
+
473
475
  /** @param {number[]} values @returns {number | null} */
474
476
  function median(values) {
475
477
  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.