@torrent-tv/proxy 2.107.41 → 2.107.43

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.41",
3
+ "version": "2.107.43",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
 
40
40
  import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
41
41
  import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
42
- import { speedFromWork } from "./encoder-readings.js";
42
+ import { workDeltaBetween } from "./encoder-readings.js";
43
43
  import { RunClock } from "./RunClock.js";
44
44
  import { RunProgress } from "./RunProgress.js";
45
45
 
@@ -229,7 +229,7 @@ export class EncodeRun {
229
229
  * @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
230
230
  * @param {() => number} [params.now]
231
231
  * @param {(ended: RunEnded) => void} [params.onEnded]
232
- * @param {(reading: { speed: number, at: number }) => void} [params.onSpeedMeasured] -
232
+ * @param {(reading: { speed: number, at: number, workSeconds: number, workingSeconds: number }) => void} [params.onSpeedMeasured] -
233
233
  * Told each time this run has measured its processing speed, once per
234
234
  * reading.
235
235
  * @param {(name: string, following: string | null) => string | null} [params.onClosed] - Called with the
@@ -353,10 +353,10 @@ export class EncodeRun {
353
353
  }
354
354
 
355
355
  /**
356
- * The same speed with the moment it was measured, or null before a closed
357
- * piece has enough progress data.
356
+ * The processing speed, its produced media and active working time, with
357
+ * the measurement time; null before a piece supplies a measured interval.
358
358
  *
359
- * @returns {{ speed: number, at: number } | null}
359
+ * @returns {{ speed: number, at: number, workSeconds: number, workingSeconds: number } | null}
360
360
  */
361
361
  get speedReading() {
362
362
  return this.#speedReading === null ? null : { ...this.#speedReading };
@@ -764,9 +764,9 @@ export class EncodeRun {
764
764
  if (sample === null) {
765
765
  return;
766
766
  }
767
- const speed = speedFromWork(this.#pieceSample, sample);
768
- if (speed !== null) {
769
- this.#speedReading = { speed, at: sample.at };
767
+ const work = workDeltaBetween(this.#pieceSample, sample);
768
+ if (work !== null) {
769
+ this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work };
770
770
  this.#pieceSample = sample;
771
771
  this.#speedMeasurementPending = false;
772
772
  this.onSpeedMeasured(this.speedReading);
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  export class SpeedReadings {
17
- /** @type {WeakMap<object, Array<{ speed: number, at: number }>>} */
17
+ /** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number }>>} */
18
18
  #byOutput = new WeakMap();
19
19
  #keepMs;
20
20
 
@@ -25,7 +25,7 @@ export class SpeedReadings {
25
25
 
26
26
  /**
27
27
  * @param {object} output
28
- * @param {{ speed: number, at: number } | null} reading
28
+ * @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number } | null} reading
29
29
  */
30
30
  note(output, reading) {
31
31
  if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
@@ -33,7 +33,12 @@ export class SpeedReadings {
33
33
  if (!readings) this.#byOutput.set(output, readings = []);
34
34
  let place = readings.length;
35
35
  while (place > 0 && readings[place - 1].at > reading.at) place -= 1;
36
- readings.splice(place, 0, { speed: reading.speed, at: reading.at });
36
+ const sample = { speed: reading.speed, at: reading.at };
37
+ if (Number.isFinite(reading.workSeconds) && reading.workSeconds > 0 &&
38
+ Number.isFinite(reading.workingSeconds) && reading.workingSeconds > 0) {
39
+ Object.assign(sample, { workSeconds: reading.workSeconds, workingSeconds: reading.workingSeconds });
40
+ }
41
+ readings.splice(place, 0, sample);
37
42
  const cutoff = readings.at(-1).at - this.#keepMs;
38
43
  // As the forecast's own trend keeps them: the newest one before the horizon stays.
39
44
  while (readings.length > 1 && readings[1].at <= cutoff) readings.shift();
@@ -41,7 +46,7 @@ export class SpeedReadings {
41
46
 
42
47
  /**
43
48
  * @param {object} output
44
- * @returns {Array<{ speed: number, at: number }>} The window, in the order the readings were taken.
49
+ * @returns {Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number }>} The readings in measurement order.
45
50
  */
46
51
  windowOf(output) {
47
52
  return [...(this.#byOutput.get(output) ?? [])];
@@ -31,13 +31,21 @@
31
31
  * @returns {number | null} Film seconds made per second of the run's own work.
32
32
  */
33
33
  export function speedFromWork(previous, current) {
34
- if (!previous || !current) {
35
- return null;
36
- }
37
- const producedSeconds = current.producedSeconds - previous.producedSeconds;
34
+ const work = workDeltaBetween(previous, current);
35
+ return work === null ? null : work.workSeconds / work.workingSeconds;
36
+ }
37
+
38
+ /**
39
+ * Produced media and active processing time between samples of one run.
40
+ * @param {WorkSample | null} previous
41
+ * @param {WorkSample | null} current
42
+ * @returns {{ workSeconds: number, workingSeconds: number } | null}
43
+ */
44
+ export function workDeltaBetween(previous, current) {
45
+ if (!previous || !current) return null;
46
+ const workSeconds = current.producedSeconds - previous.producedSeconds;
38
47
  const workingSeconds = (current.workingMs - previous.workingMs) / 1000;
39
- if (!(producedSeconds > 0) || !(workingSeconds > 0)) {
40
- return null;
41
- }
42
- return producedSeconds / workingSeconds;
48
+ return Number.isFinite(workSeconds) && workSeconds > 0 &&
49
+ Number.isFinite(workingSeconds) && workingSeconds > 0
50
+ ? { workSeconds, workingSeconds } : null;
43
51
  }
@@ -76,12 +76,13 @@ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0, wei
76
76
  * ordered by when each was taken, readings taken in the same millisecond kept
77
77
  * apart. Null for no reading.
78
78
  *
79
- * @param {Array<{ at: number, speed?: number, value?: number }>} readings
79
+ * @param {Array<{ at: number, speed?: number, value?: number, workSeconds?: number, workingSeconds?: number }>} readings
80
80
  * @returns {object | null}
81
81
  */
82
82
  export function rateSummaryOf(readings) {
83
83
  const ordered = (Array.isArray(readings) ? readings : [])
84
- .map(reading => ({ at: reading?.at, value: reading?.value ?? reading?.speed }))
84
+ .map(reading => ({ at: reading?.at, value: reading?.value ?? reading?.speed,
85
+ workSeconds: reading?.workSeconds, workingSeconds: reading?.workingSeconds }))
85
86
  .filter(reading => Number.isFinite(reading.at) && Number.isFinite(reading.value) && reading.value >= 0)
86
87
  .sort((left, right) => left.at - right.at);
87
88
  return ordered.length > 0 ? summarizeRate(ordered) : null;
@@ -278,9 +279,9 @@ export function predictPlaybackReadiness(input = {}) {
278
279
  [{ start: at, end: add(at, measured ? nearest(fromSeconds(buffered), unit) :
279
280
  rescale(fromSeconds(buffered), unit)), frame: zero }],
280
281
  ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
281
- // Film made over the encoder's working time, over the readings the trend
282
- // keeps: one reading is one closed piece, so the harmonic mean is that
283
- // ratio. The last reading alone is one piece's content and machine — a
282
+ // Film made over active processing time, summed over the readings retained.
283
+ // Point-only legacy readings retain their equal-work harmonic summary.
284
+ // The last reading alone is one piece's content and machine — a
284
285
  // first piece of credits read 6.49x and started a cold AVI on 2.4 s of
285
286
  // buffer that the next piece, at 0.57x, did not hold (torrent-tv/meta#178).
286
287
  curve: rateCurve(track.readings, now, "piece"),
@@ -687,16 +688,8 @@ export function predictPlaybackReadiness(input = {}) {
687
688
  }
688
689
  const tailStock = [];
689
690
  if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
690
- // Where each source's bytes stop being the near window's: the last byte
691
- // its pieces read, in the file's own offsets.
692
- const windowEnds = new Map();
693
- for (const { sourceByteIntervals } of jobs) {
694
- for (const [sourceId, ranges] of sourceByteIntervals) {
695
- for (const range of ranges) windowEnds.set(sourceId, Math.max(windowEnds.get(sourceId) ?? 0, range.end));
696
- }
697
- }
698
691
  const stop = scheduleTail({ trackState, sourceState, runStarts, trackFinish,
699
- link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock, windowEnds });
692
+ link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock });
700
693
  if (stop) {
701
694
  return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
702
695
  ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
@@ -799,7 +792,7 @@ function isPiece(segment) {
799
792
  * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
800
793
  */
801
794
  function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
802
- position, buffered, reserve, transferFinish, constrain, duration, stock = null, windowEnds = null }) {
795
+ position, buffered, reserve, transferFinish, constrain, duration, stock = null }) {
803
796
  const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
804
797
  if (!Number.isFinite(tailStart)) return null;
805
798
  const sourceDurations = new Map();
@@ -823,11 +816,17 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
823
816
  stock?.push({ sourceId, skipped: "complete" });
824
817
  continue;
825
818
  }
826
- // Held means on this proxy now, past the last byte the near window's
827
- // pieces read: those are counted above, and the window's planned arrivals
828
- // counted again as held took 117 s of film off a 90-minute tail. The
829
- // torrent thread's plan is not that end: it can reach the end of the file.
830
- const windowEnd = windowEnds?.get(sourceId) ?? 0;
819
+ // Held means on this proxy now, in the bytes of the film after the near
820
+ // window: the window's own bytes are counted above, and its planned
821
+ // arrivals counted again as held took 117 s of film off a 90-minute tail.
822
+ // Where those bytes begin is placed by the density the tail is priced at.
823
+ // Neither the torrent thread's plan nor the bytes the window reads are
824
+ // that place: both can reach the end of the file — a copied Matroska file
825
+ // reads its index there — and the whole of Despicable Me 3 then counted
826
+ // as held (Home Assistant 2026-10-26, torrent-tv/meta#239).
827
+ const fileLength = Number(source.source.fileLength);
828
+ const windowEnd = fileLength > 0 && duration > 0
829
+ ? Math.min(fileLength, Math.max(0, fileLength * tailStart / duration)) : 0;
831
830
  const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
832
831
  heldBeyond(source.source.residence, windowEnd), windowEnd);
833
832
  if (bytes === null) return { reason: "source-measurement-unavailable",
@@ -1419,6 +1418,10 @@ function rateCurve(readings, now, weighting = "last") {
1419
1418
 
1420
1419
  function summarizeRate(readings) {
1421
1420
  const latest = readings.at(-1);
1421
+ const workKnown = readings.every(reading => Number.isFinite(reading.workSeconds) && reading.workSeconds > 0 &&
1422
+ Number.isFinite(reading.workingSeconds) && reading.workingSeconds > 0);
1423
+ const workSeconds = workKnown ? readings.reduce((sum, reading) => sum + reading.workSeconds, 0) : null;
1424
+ const workingSeconds = workKnown ? readings.reduce((sum, reading) => sum + reading.workingSeconds, 0) : null;
1422
1425
  let weighted = 0;
1423
1426
  for (let index = 0; index + 1 < readings.length; index += 1) {
1424
1427
  weighted += readings[index].value * (readings[index + 1].at - readings[index].at);
@@ -1429,12 +1432,13 @@ function summarizeRate(readings) {
1429
1432
  lastAt: latest.at,
1430
1433
  lastValue: latest.value,
1431
1434
  meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
1432
- // Each reading one piece of work: work over the time it took.
1435
+ // Equal-work summary for legacy readings without measured work quantities.
1433
1436
  harmonicValue: readings.some(reading => !(reading.value > 0)) ? 0
1434
1437
  : readings.length / readings.reduce((sum, reading) => sum + 1 / reading.value, 0),
1435
1438
  // Each reading holding until the next: quantity over the span.
1436
1439
  timeWeightedValue: spanMs > 0 ? weighted / spanMs : latest.value,
1437
- spanSeconds: spanMs / 1000
1440
+ spanSeconds: spanMs / 1000,
1441
+ ...(workKnown ? { workSeconds, workingSeconds, workRateValue: workSeconds / workingSeconds } : {})
1438
1442
  };
1439
1443
  }
1440
1444
 
@@ -1442,7 +1446,8 @@ function rateCurveFrom(summary, weighting = "last") {
1442
1446
  // Conditional projection of the measured service. No exponential
1443
1447
  // relaxation, smoothing time, slope extrapolation or guessed recovery.
1444
1448
  // A new measurement changes the integral; polling does not change its rate.
1445
- const value = weighting === "piece" && Number.isFinite(summary.harmonicValue) ? summary.harmonicValue
1449
+ const value = weighting === "piece" && Number.isFinite(summary.workRateValue) ? summary.workRateValue
1450
+ : weighting === "piece" && Number.isFinite(summary.harmonicValue) ? summary.harmonicValue
1446
1451
  : weighting === "time" && Number.isFinite(summary.timeWeightedValue) ? summary.timeWeightedValue
1447
1452
  : summary.lastValue;
1448
1453
  const rate = Math.max(0, value);