@torrent-tv/proxy 2.32.0 → 2.33.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2.33.0
2
+
3
+ - **New**: A quality step is judged on the machine it will actually run on. The encoder benchmark measures a QUIET host — one ffmpeg and nothing else — while the addon host was measured 99 % busy, and a step predicted at 1.83x ran at 1.01-1.12x (2026-08-17). The offer now multiplies each prediction by the share of the machine that is free, taken from the same `host-load` reading that is already printed every five seconds. What is subtracted is ONLY the work nobody has been charged for — the kernel, the container, whatever else the owner runs — because our own encoders are already priced by the concurrency arithmetic and the proxy's own work per megabyte moved. Charging those here as well is what shipped in 2.21.0 and emptied the quality menu down to a single copied height. On the field reading the correction is about 0.77, and the "not offering" line now says what the machine had to spare when it decided.
4
+ - **New**: Each step reports what its prediction was worth. When a step runs with the machine to itself, the log states the speed it was predicted at, the speed it measured, and the ratio — so the error that REMAINS after the availability correction is a number in the field rather than an argument. It is written when it moves by more than a tenth, so a steady step says it once. On the field case that correction takes 1.83x to 1.41x against 1.01-1.12x measured: part of the gap, not all of it, and this line is how the rest gets found.
5
+
1
6
  ## 2.32.0
2
7
 
3
8
  - **New**: The decode cost is fitted from a clip set that can be checked, and a term the measurements do not determine is refused instead of published as a zero. The set that shipped until now was three clips for three unknowns — an EXACT system, with two of the clips at the same pixel rate — and such a system cannot fail visibly: it returns whatever satisfies its equations. On 2026-08-17 it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`, so a film's own bitrate never entered its price, and the prediction built on it was 1.8-2.2x optimistic against the same file measured while playing. The new set is six clips — three sizes × two bitrates, the axes varied INDEPENDENTLY — cut from the same Netflix Open Content "Meridian" footage (CC BY 4.0, `assets/calibration/NOTICE.md`), 7.7 MB against 8.8 MB before. Three spare measurements give the fit a residual, and with it two questions it could not ask before: whether a term's whole effect across the measured range exceeds the scatter, and whether the coefficient exceeds its own standard error. A term that fails either is dropped, the rest are fitted again, and the log names it — a zero now means "not measured" only when it says so. A NEGATIVE coefficient is dropped too rather than clamped to zero: more pixels cannot cost less work, so a negative fit is noise beating an effect, not a discovery about the host. Measured on the developer's machine, the new set determines all three terms (`0.000520 × Mpx/s + 0.002086 × Mbit/s + 0.0033 s/s`, typical disagreement 0.0012 s/s), and the bitrate term it recovers matches the difference between the two 1080p clips to 15 %. The arithmetic is a pure module with the degenerate case as a test (`services/decode-cost-fit.js`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file How much of the machine a new encoder can actually have.
3
+ *
4
+ * The encoder benchmark measures a QUIET host: one ffmpeg, nothing else. A real
5
+ * encode runs on a machine that is also downloading, hashing and serving, and
6
+ * on the addon host that machine was measured 99 % busy — `ffmpeg=52-60%
7
+ * proxy=17-24% system=99%` — while a step predicted at 1.83x ran at 1.01-1.12x
8
+ * (2026-08-17).
9
+ *
10
+ * What this corrects is ONLY the part nobody has been charged for. That
11
+ * distinction is the whole of the file, because the alternative has already
12
+ * shipped once and broke the product: in 2.21.0 the budget ADDED what else was
13
+ * running while the per-file costs were being LEARNED from runs that already
14
+ * contained that other work, so every cost was counted twice, every re-encoded
15
+ * step was refused, and the quality menu emptied itself down to the one copied
16
+ * height (fixed in 2.21.1).
17
+ *
18
+ * So: our own encoders are priced by the concurrency arithmetic, and the
19
+ * proxy's own work — the torrent, the hashing, the delivery — is priced per
20
+ * megabyte moved. Both are already in the budget. What is NOT in it is
21
+ * everything else the machine does: the kernel, the container runtime, whatever
22
+ * else the owner runs on their box. That is what is subtracted here, and
23
+ * nothing more.
24
+ */
25
+
26
+ /**
27
+ * The share of the machine available to a new encoder.
28
+ *
29
+ * @param {object} reading - Fractions of the WHOLE machine (all cores), as
30
+ * `shareOfMachine` reports them.
31
+ * @param {number | null} reading.systemBusy - Everything the machine is doing.
32
+ * @param {number | null} reading.encoderShare - Our own ffmpeg processes.
33
+ * @param {number | null} reading.proxyShare - The proxy process itself.
34
+ * @returns {{ share: number, unattributed: number, known: boolean }}
35
+ * `known` is false when the host does not report its own load — then the
36
+ * share is 1 and the caller must say it is uncorrected rather than pretend.
37
+ */
38
+ export function availableShareFrom(reading = {}) {
39
+ const systemBusy = finite(reading.systemBusy);
40
+ if (systemBusy === null) {
41
+ // No reading at all: not every host has /proc. An uncorrected prediction is
42
+ // the honest answer, and the caller says so.
43
+ return { share: 1, unattributed: 0, known: false };
44
+ }
45
+ const ours = (finite(reading.encoderShare) ?? 0) + (finite(reading.proxyShare) ?? 0);
46
+ // Rounding, and the two readings being taken microseconds apart, can put our
47
+ // own share fractionally above the system total. Below zero is not a
48
+ // measurement of anything.
49
+ const unattributed = clamp(systemBusy - ours, 0, 1);
50
+ return { share: clamp(1 - unattributed, 0, 1), unattributed, known: true };
51
+ }
52
+
53
+ /**
54
+ * Apply the correction to a predicted speed.
55
+ *
56
+ * A speed is work per unit time, so a machine that can give a new encoder only
57
+ * `share` of itself produces `share ×` the speed the benchmark measured alone.
58
+ *
59
+ * @param {number} predictedSpeed - From the quiet-host benchmark.
60
+ * @param {{ share: number, known: boolean }} availability
61
+ * @returns {number}
62
+ */
63
+ export function correctForAvailability(predictedSpeed, availability) {
64
+ if (!Number.isFinite(predictedSpeed) || predictedSpeed <= 0) {
65
+ return predictedSpeed;
66
+ }
67
+ if (!availability?.known) {
68
+ return predictedSpeed;
69
+ }
70
+ return predictedSpeed * availability.share;
71
+ }
72
+
73
+ /**
74
+ * @param {unknown} value
75
+ * @returns {number | null}
76
+ */
77
+ function finite(value) {
78
+ return Number.isFinite(value) ? Number(value) : null;
79
+ }
80
+
81
+ /**
82
+ * @param {number} value
83
+ * @param {number} low
84
+ * @param {number} high
85
+ * @returns {number}
86
+ */
87
+ function clamp(value, low, high) {
88
+ return Math.min(high, Math.max(low, value));
89
+ }
@@ -20,6 +20,7 @@ import { logger } from "../utils/logger.js";
20
20
  import { readKeyframeIndex } from "./container-index/index.js";
21
21
  import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
22
22
  import { speedFromReadings } from "./encoder-readings.js";
23
+ import { availableShareFrom, correctForAvailability } from "./available-share.js";
23
24
  import {
24
25
  ENCODE_RUN_EVENT,
25
26
  ENCODE_RUN_STATE,
@@ -1902,6 +1903,12 @@ export class HlsSessionManager {
1902
1903
  // software hosts, else the client target). 0 = keep source.
1903
1904
  encodeWidth,
1904
1905
  encodeHeight,
1906
+ // What the offer predicted this height would do on this machine, so the
1907
+ // field can say what the prediction was worth once the step runs. Null
1908
+ // when the step was never judged — a copied stream needs no encoder and
1909
+ // is never predicted.
1910
+ predictedSpeedWhenOffered: this.lastPredictedByHeight?.get(encodeHeight) ?? null,
1911
+ lastPredictionRatio: null,
1905
1912
  // The NAME of this rung, fixed at the height that was asked for. It is
1906
1913
  // deliberately not the height being encoded: a viewer who picked 480p on
1907
1914
  // a host that then starts them at 360p, or steps down to it later, goes
@@ -3217,13 +3224,25 @@ export class HlsSessionManager {
3217
3224
  const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
3218
3225
  ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
3219
3226
  : null;
3227
+ // Kept for the quality offer, which predicts from a benchmark taken on a
3228
+ // QUIET host: the same reading that is printed here says how much of the
3229
+ // machine a new encoder could actually have. Only what nobody has been
3230
+ // charged for is subtracted — see `available-share.js`.
3231
+ this.hostAvailability = availableShareFrom({
3232
+ systemBusy: share.systemShare,
3233
+ encoderShare: share.processShare,
3234
+ proxyShare
3235
+ });
3220
3236
  logger.info(
3221
3237
  `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
3222
3238
  `system=${asPercent(share.systemShare)} ` +
3223
3239
  `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
3224
3240
  `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
3225
3241
  `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
3226
- ` over=${share.elapsedSec.toFixed(1)}s`
3242
+ ` over=${share.elapsedSec.toFixed(1)}s` +
3243
+ // What the offer will multiply a prediction by, in the same line as the
3244
+ // readings it comes from.
3245
+ ` available=${asPercent(this.hostAvailability.share)}`
3227
3246
  );
3228
3247
  }
3229
3248
 
@@ -3391,6 +3410,10 @@ export class HlsSessionManager {
3391
3410
  // although nothing is producing that step any more. The next reading of the
3392
3411
  // new encode replaces it.
3393
3412
  session.lastAloneSpeed = null;
3413
+ // And so is the prediction it was compared against: it described the step
3414
+ // this session has just left.
3415
+ session.predictedSpeedWhenOffered = this.lastPredictedByHeight?.get(rung.height) ?? null;
3416
+ session.lastPredictionRatio = null;
3394
3417
  // Priced the same way the offer and the starting rung are. Choosing the
3395
3418
  // preset on the encoder alone treats decoding as free, which is how the
3396
3419
  // check and the encode came to disagree in the first place — and here it
@@ -5444,6 +5467,25 @@ export class HlsSessionManager {
5444
5467
  // 0.3x reads as 3.33 s of work per second of video, more than the machine
5445
5468
  // has — and every other quality step was refused on the download's account.
5446
5469
  session.lastAloneSpeed = speed;
5470
+ // What the offer predicted for this very step, against what it then did
5471
+ // with the machine to itself. The prediction is corrected for the share of
5472
+ // the machine that was free at the time, so this ratio is the error that
5473
+ // remains AFTER that correction — which is the only way to tell whether a
5474
+ // stage of roadmap item 3 moved anything. Written when it changes by more
5475
+ // than a tenth, so a steady step says it once rather than every five
5476
+ // seconds.
5477
+ if (Number.isFinite(session.predictedSpeedWhenOffered) && session.predictedSpeedWhenOffered > 0) {
5478
+ const ratio = speed / session.predictedSpeedWhenOffered;
5479
+ const lastSaid = session.lastPredictionRatio;
5480
+ if (!Number.isFinite(lastSaid) || Math.abs(ratio - lastSaid) > 0.1) {
5481
+ session.lastPredictionRatio = ratio;
5482
+ logger.info(
5483
+ `prediction ${session.id.slice(0, 8)} ${session.encodeHeight || "source"}p: ` +
5484
+ `predicted ${session.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
5485
+ `(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)`
5486
+ );
5487
+ }
5488
+ }
5447
5489
  if (kind === "audio") {
5448
5490
  // What is left after the work that was already accounted for. `null` when
5449
5491
  // the subtraction leaves nothing positive, which means the reading says
@@ -5962,6 +6004,12 @@ export class HlsSessionManager {
5962
6004
  const kept = [];
5963
6005
  /** @type {string[]} */
5964
6006
  const dropped = [];
6007
+ // What each height was predicted to do on THIS machine, kept so a session
6008
+ // started at that height can be compared against it once it runs. The
6009
+ // manager holds the last answer, because the offer is computed on the path
6010
+ // that serves every request while a session is created elsewhere.
6011
+ /** @type {Map<number, number | null>} */
6012
+ const predictedByHeight = new Map();
5965
6013
  for (const height of heights) {
5966
6014
  // The height an encoder is ALREADY producing, and the source's own height
5967
6015
  // when the FAMILY serves it by copy — neither has to be predicted,
@@ -6004,7 +6052,7 @@ export class HlsSessionManager {
6004
6052
  0,
6005
6053
  concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
6006
6054
  );
6007
- const { speed, sustainable } = canSustainOutput({
6055
+ const { speed } = canSustainOutput({
6008
6056
  benchmark,
6009
6057
  decodeModel: this.decodeCostModel,
6010
6058
  source,
@@ -6012,11 +6060,24 @@ export class HlsSessionManager {
6012
6060
  observedDecodeCostSec,
6013
6061
  concurrentCostSec: concurrentBesideThis
6014
6062
  });
6015
- if (sustainable) {
6063
+ // The benchmark behind that figure was taken on a QUIET host — one
6064
+ // ffmpeg and nothing else. The machine a step will actually run on is
6065
+ // also running the kernel, the container and whatever else its owner
6066
+ // does, and on the addon host that was measured at 99 % busy with a
6067
+ // quarter of it unattributed. Only the unattributed part is charged
6068
+ // here: our own encoders are already in `concurrentBesideThis` and the
6069
+ // proxy's own work is already priced per megabyte moved.
6070
+ const onThisMachine = correctForAvailability(speed, this.hostAvailability);
6071
+ // Kept against the step's own session, so that when it runs the field
6072
+ // says what the prediction was worth. Without this the only comparison
6073
+ // available is between two figures written minutes apart in different
6074
+ // lines of the log.
6075
+ predictedByHeight.set(height, onThisMachine);
6076
+ if (onThisMachine !== null && onThisMachine >= REALTIME_SPEED_MARGIN) {
6016
6077
  kept.push(height);
6017
6078
  continue;
6018
6079
  }
6019
- dropped.push(`${height}p=${speed === null ? "n/a" : `${speed.toFixed(2)}x`}`);
6080
+ dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
6020
6081
  }
6021
6082
  // Written when the ANSWER changes, not when the answer is recomputed. This
6022
6083
  // is asked on the path that serves every playlist, init and segment, and
@@ -6026,12 +6087,19 @@ export class HlsSessionManager {
6026
6087
  if (dropped.length > 0) {
6027
6088
  const line =
6028
6089
  `transcode: not offering ${dropped.join(" ")} — below realtime × ${REALTIME_SPEED_MARGIN} ` +
6090
+ // Said with the figures, because a step refused on a busy machine and
6091
+ // one refused on an idle machine are different facts about the host.
6092
+ (this.hostAvailability?.known
6093
+ ? `on a machine with ${Math.round(this.hostAvailability.share * 100)}% to spare `
6094
+ : "") +
6029
6095
  `(offering ${kept.map((height) => `${height}p`).join(" ")})`;
6030
6096
  if (line !== this.#lastOfferLine) {
6031
6097
  this.#lastOfferLine = line;
6032
6098
  logger.info(line);
6033
6099
  }
6100
+ this.lastPredictedByHeight = predictedByHeight;
6034
6101
  } else {
6102
+ this.lastPredictedByHeight = predictedByHeight;
6035
6103
  this.#lastOfferLine = "";
6036
6104
  }
6037
6105
  return kept;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @file What the machine can spare for a new encoder — and what must not be
3
+ * charged twice.
4
+ *
5
+ * The numbers are the addon host's own, measured 2026-08-17 while a 240p step
6
+ * ran at 1.01-1.12x against a prediction of 1.83x.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import { availableShareFrom, correctForAvailability } from "../services/available-share.js";
13
+
14
+ test("only the work nobody has been charged for is subtracted", () => {
15
+ // The field reading: the box is saturated, our encoder has more than half of
16
+ // it, and the proxy's own work a fifth.
17
+ const availability = availableShareFrom({ systemBusy: 0.99, encoderShare: 0.56, proxyShare: 0.2 });
18
+
19
+ assert.equal(availability.known, true);
20
+ // 0.99 − 0.76 = 0.23 belongs to the kernel, the container and whatever else
21
+ // the owner runs. Our own encoders are priced by the concurrency arithmetic
22
+ // and the proxy's work per megabyte moved; charging them here as well is the
23
+ // double counting that emptied the quality menu in 2.21.0.
24
+ assert.ok(Math.abs(availability.unattributed - 0.23) < 1e-9, `${availability.unattributed}`);
25
+ assert.ok(Math.abs(availability.share - 0.77) < 1e-9, `${availability.share}`);
26
+ });
27
+
28
+ test("a quiet machine is not corrected at all", () => {
29
+ const availability = availableShareFrom({ systemBusy: 0.05, encoderShare: 0.04, proxyShare: 0.01 });
30
+ assert.equal(availability.unattributed, 0);
31
+ assert.equal(availability.share, 1);
32
+ assert.equal(correctForAvailability(4, availability), 4);
33
+ });
34
+
35
+ test("a host that cannot say says so, and nothing is corrected", () => {
36
+ // Not every host has /proc. An uncorrected figure is honest; a figure
37
+ // corrected by an invented share is not.
38
+ const availability = availableShareFrom({ systemBusy: null, encoderShare: null, proxyShare: null });
39
+ assert.equal(availability.known, false);
40
+ assert.equal(availability.share, 1);
41
+ assert.equal(correctForAvailability(2.5, availability), 2.5, "an unknown machine leaves the prediction alone");
42
+ assert.equal(availableShareFrom().known, false);
43
+ });
44
+
45
+ test("readings that overlap by a rounding do not produce a negative machine", () => {
46
+ // The two samples are taken microseconds apart, so our own share can come out
47
+ // fractionally above the system total.
48
+ const availability = availableShareFrom({ systemBusy: 0.60, encoderShare: 0.58, proxyShare: 0.05 });
49
+ assert.equal(availability.unattributed, 0);
50
+ assert.equal(availability.share, 1);
51
+ });
52
+
53
+ test("a machine given over entirely to other work leaves nothing", () => {
54
+ const availability = availableShareFrom({ systemBusy: 1, encoderShare: 0, proxyShare: 0 });
55
+ assert.equal(availability.share, 0);
56
+ assert.equal(correctForAvailability(4, availability), 0, "a step cannot be offered on a machine with nothing left");
57
+ });
58
+
59
+ test("the correction is proportional, and only ever downwards", () => {
60
+ const availability = availableShareFrom({ systemBusy: 0.99, encoderShare: 0.56, proxyShare: 0.2 });
61
+ // The field case: a step predicted at 1.83x on a quiet box.
62
+ const corrected = correctForAvailability(1.83, availability);
63
+ assert.ok(Math.abs(corrected - 1.4091) < 0.001, `${corrected}`);
64
+ // And the honest note this test exists to record: 1.41x is still above the
65
+ // 1.01-1.12x that step actually ran at. This correction closes part of the
66
+ // gap, not all of it — which is why the per-step field comparison ships with
67
+ // it rather than after it.
68
+ assert.ok(corrected > 1.12, "the remaining difference is what the field check is for");
69
+ });
70
+
71
+ test("nothing is corrected without a prediction to correct", () => {
72
+ const availability = availableShareFrom({ systemBusy: 0.99, encoderShare: 0.5, proxyShare: 0.2 });
73
+ assert.equal(correctForAvailability(0, availability), 0);
74
+ assert.equal(correctForAvailability(Number.NaN, availability), Number.NaN);
75
+ assert.equal(correctForAvailability(-1, availability), -1);
76
+ });