@torrent-tv/proxy 2.101.30 → 2.101.32

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.30",
3
+ "version": "2.101.32",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -290,86 +290,30 @@ export class LocalObservations {
290
290
  }
291
291
 
292
292
  /**
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.
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.
298
297
  *
299
298
  * @param {object} features
300
299
  * @returns {number | null}
301
300
  */
302
301
  firstFrameSeconds(features) {
303
302
  if (!features || typeof features !== "object") return null;
304
- const candidates = (this.#entries.firstFrame[this.#firstFrameKey()] ?? [])
305
- .filter(reading => reading.extension === (features.extension ?? "") && reading.stages);
306
- if (candidates.length === 0) return null;
307
- const dimensions = ["fileLength", "downloadSpeed", "linkMbps", "linkSampleAgeMs", "preparedOutputBytes",
308
- "fileAvailable", "headerRemaining", "peers", "videoOperation", "audioOperation", "videoSpeedX", "audioSpeedX"];
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])))
313
- .filter(value => value !== null && value > 0))]));
314
- const distances = [];
315
- for (const reading of candidates) {
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;
350
- used += 1;
351
- }
352
- if (used === 0 || shared === 0) continue;
353
- distance /= used;
354
- if (bestDistance === null || distance < bestDistance) bestDistance = distance;
355
- }
356
- if (bestDistance !== null) distances.push({ distance: bestDistance, reading });
357
- }
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
303
  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;
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);
311
+ }
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;
373
317
  }
374
318
 
375
319
  #firstFrameKey() {
@@ -487,6 +431,20 @@ function operationOf(value) {
487
431
  return value === "copy" || value === "encode" ? value : null;
488
432
  }
489
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
+
490
448
  /** @param {unknown} value @returns {{ totalMs: number, transportMs: number, planMs: number, prepareMs: number, prebufferMs: number, presentationMs: number } | null} */
491
449
  function stagesOf(value) {
492
450
  if (!value || typeof value !== "object") return null;
@@ -132,7 +132,9 @@ export class ViewerRequests {
132
132
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
133
133
  if (changed) context.revision += 1;
134
134
  this.#pruneEarlyWaits();
135
- const totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
135
+ const totalSeconds = Number.isFinite(context.activeTotalSeconds)
136
+ ? context.activeTotalSeconds
137
+ : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
136
138
  const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
137
139
  if (!Number.isFinite(seconds) || seconds < 0) return null;
138
140
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
@@ -144,7 +146,9 @@ export class ViewerRequests {
144
146
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
145
147
  if (sameWait && changed) context.revision += 1;
146
148
  this.#pruneEarlyWaits();
147
- const totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
149
+ const totalSeconds = Number.isFinite(context.activeTotalSeconds)
150
+ ? context.activeTotalSeconds
151
+ : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
148
152
  const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
149
153
  if (!Number.isFinite(seconds) || seconds < 0) return null;
150
154
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
@@ -187,14 +191,22 @@ export class ViewerRequests {
187
191
  const revision = sameWait ? current.revision : 0;
188
192
  const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
189
193
  sourceObserved: true, waitElapsedMs });
190
- const changed = this.#recordEarlyWaitSnapshot(context, context.features);
194
+ const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
195
+ const elapsedMs = waitElapsedSince(context, waitElapsedMs);
196
+ const measuredWorkSeconds = processing.firstRequiredSegmentsSeconds;
197
+ const hasActiveEstimate = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0;
198
+ const activeTotalSeconds = hasActiveEstimate
199
+ ? elapsedMs / 1000 + measuredWorkSeconds
200
+ : context.activeTotalSeconds;
201
+ const activeEstimateChanged = hasActiveEstimate && activeTotalSeconds !== context.activeTotalSeconds;
202
+ if (hasActiveEstimate) context.activeTotalSeconds = activeTotalSeconds;
203
+ const changed = featuresChanged || activeEstimateChanged;
191
204
  if (sameWait && changed) context.revision += 1;
192
205
  this.#pruneEarlyWaits();
193
- const measuredWorkSeconds = processing.firstRequiredSegmentsSeconds;
194
- const totalSeconds = Number.isFinite(measuredWorkSeconds) && measuredWorkSeconds >= 0
195
- ? waitElapsedSince(context, waitElapsedMs) / 1000 + measuredWorkSeconds
206
+ const totalSeconds = Number.isFinite(activeTotalSeconds)
207
+ ? activeTotalSeconds
196
208
  : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
197
- const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
209
+ const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
198
210
  if (!Number.isFinite(seconds) || seconds < 0) return null;
199
211
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
200
212
  }