@torrent-tv/proxy 2.33.0 → 2.34.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.34.0
2
+
3
+ - **New**: The proxy tells the browser the smallest buffer at which no interruption reaches the viewer, measured on the file being watched. It is one whole segment — the one being played — plus the worst wait its own reader met before the buffer could refill, from that file's recent interruptions on that swarm. On the field torrent of 2026-08-17 that is 7-9 s, where the browser has been waiting for a hand-chosen 25: sixteen seconds of spinner that nothing had shown to be necessary. Null until the reader has seen two interruptions — one wait shows no interval, and an interval invented from one point is what this work exists to remove — and the browser keeps its own figure until then. The reader measures it, the session manager states it with its own segment length, and the progress reply carries it.
4
+ - **Chore**: Removed `services/torrent-worker/supply-interruptions.js`, a second copy of the same arithmetic that was wired to nothing.
5
+
1
6
  ## 2.33.0
2
7
 
3
8
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.33.0",
3
+ "version": "2.34.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": {
@@ -21,6 +21,7 @@ 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
23
  import { availableShareFrom, correctForAvailability } from "./available-share.js";
24
+ import { minimumBufferFrom } from "./supply-margin.js";
24
25
  import {
25
26
  ENCODE_RUN_EVENT,
26
27
  ENCODE_RUN_STATE,
@@ -3368,6 +3369,12 @@ export class HlsSessionManager {
3368
3369
  if (!stats) {
3369
3370
  return "unknown";
3370
3371
  }
3372
+ // What this file's own interruptions demand, measured by the reader. Kept
3373
+ // on the session because the browser is told the buffer that follows from
3374
+ // it, and because the quality offer will be held to the speed it names.
3375
+ if (stats.supply) {
3376
+ session.supplyFigures = stats.supply;
3377
+ }
3371
3378
  // A fully (or almost fully) downloaded file cannot be download-bound.
3372
3379
  if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
3373
3380
  return "cpu";
@@ -7589,6 +7596,17 @@ export class HlsSessionManager {
7589
7596
  // the browser tracks its sessions by the id it was given.
7590
7597
  sessionId: named.id,
7591
7598
  state: wireState(session.runState),
7599
+ // The smallest buffer at which no interruption reaches the viewer, from
7600
+ // THIS file's own recent interruptions: one whole segment — the one being
7601
+ // played — plus the worst wait that can arrive before the buffer refills.
7602
+ // On the field torrent that is 7-9 s where the browser waits for a
7603
+ // hand-chosen 25, which is sixteen seconds of staring at a spinner that
7604
+ // nothing had shown to be necessary. Null until the reader has seen two
7605
+ // interruptions; the browser keeps its own figure until then.
7606
+ minimumBufferSeconds: minimumBufferFrom({
7607
+ segmentSeconds: this.segmentDurationSec,
7608
+ worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
7609
+ })?.seconds ?? null,
7592
7610
  processedSeconds: session.progress.processedSeconds,
7593
7611
  startPositionSeconds: session.progress.startPositionSeconds ?? 0,
7594
7612
  totalSeconds: session.progress.totalSeconds,
@@ -340,6 +340,39 @@ const supplyReportedAt = new Map();
340
340
  * @param {number} waitedMs
341
341
  * @returns {void}
342
342
  */
343
+ /**
344
+ * What this file's recent interruptions demand, for a caller that has to decide
345
+ * something with them.
346
+ *
347
+ * Exported because the figures are measured HERE — the reader is the only place
348
+ * that knows how long it waited — while the decisions they feed are made
349
+ * elsewhere: the smallest buffer that hides an interruption goes to the browser,
350
+ * and the speed a step must sustain goes to the quality offer.
351
+ *
352
+ * @param {string} infoHash
353
+ * @param {string} fileName
354
+ * @param {number} segmentSeconds - The session's own segment duration.
355
+ * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
356
+ */
357
+ export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
358
+ const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
359
+ const demand = requiredSpeedFrom(history ?? []);
360
+ if (!demand) {
361
+ return null;
362
+ }
363
+ const buffer = minimumBufferFrom({
364
+ segmentSeconds,
365
+ worstSupplyWaitSec: demand.worstWaitSec
366
+ });
367
+ return {
368
+ requiredSpeed: demand.requiredSpeed,
369
+ worstWaitSec: demand.worstWaitSec,
370
+ medianIntervalSec: demand.medianIntervalSec,
371
+ samples: demand.samples,
372
+ minimumBufferSec: buffer ? buffer.seconds : null
373
+ };
374
+ }
375
+
343
376
  function noteSupplyWait(key, label, waitedMs) {
344
377
  const history = supplyWaits.get(key) ?? [];
345
378
  history.push({ waitedMs, at: Date.now() });
@@ -26,7 +26,7 @@ import "./install-webrtc-shim.js";
26
26
  import { parentPort, workerData } from "node:worker_threads";
27
27
  import { createSendStream } from "./channel.js";
28
28
  import { createFileClaims } from "./file-claims.js";
29
- import { readFragments } from "./piece-reader.js";
29
+ import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
30
  import { Command, Event } from "./protocol.js";
31
31
 
32
32
  // Imported dynamically, and that is load-bearing: static imports are RESOLVED
@@ -358,9 +358,20 @@ async function runCommand(command, params, id) {
358
358
 
359
359
  case Command.FILE_STATS: {
360
360
  const torrent = await requireTorrent(params.sourceKey);
361
- return pool.getFileStats(torrent, params.fileIndex, {
361
+ const stats = pool.getFileStats(torrent, params.fileIndex, {
362
362
  resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
363
363
  });
364
+ // What this file's own interruptions demand, measured by the reader in
365
+ // this thread. It travels with the stats because the caller asking for
366
+ // them is the one that has to decide with them — the browser's smallest
367
+ // safe buffer, and the speed a quality step must sustain. Null until a
368
+ // second interruption has been seen: one wait shows no interval, and an
369
+ // interval invented from one point is exactly what this work removes.
370
+ const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
371
+ return {
372
+ ...stats,
373
+ supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4)
374
+ };
364
375
  }
365
376
 
366
377
  case Command.PRIORITIZE: {
@@ -1,155 +0,0 @@
1
- /**
2
- * @file What the supply's own interruptions require of a quality step, and of
3
- * the buffer in front of the viewer.
4
- *
5
- * Both figures are chosen by hand today — a speed margin of 1.5 and a 25-second
6
- * prebuffer — and both stand for a quantity that is measured every few seconds
7
- * anyway: how long a read waits for a piece, and how often that happens.
8
- *
9
- * The arithmetic, from the measurements of 2026-08-17:
10
- *
11
- * A step producing at speed `v` gains `v - 1` seconds of cushion per second
12
- * of playback. An interruption of `W` seconds costs `W`. So a step survives
13
- * its own supply exactly when it can rebuild what one interruption takes
14
- * before the next one arrives:
15
- *
16
- * (v - 1) × T > W ⇔ v > 1 + W / T
17
- *
18
- * On the field torrent that day: waits of 1.49 s median, 3.16 s worst, one
19
- * every 2.22 s → a required speed of 1.67 against the 1.5 chosen by hand, and
20
- * against 1.05 actually measured, which is why it stalled. A copy running at
21
- * 8x gains 15.5 s between interruptions against 1.5-4.8 s lost, which is the
22
- * same formula explaining why a copy never stalls.
23
- *
24
- * The buffer follows from the same numbers: it must hold the segment being
25
- * played, whole, plus the worst interruption that can arrive before it can be
26
- * refilled — whichever source that interruption comes from.
27
- *
28
- * B_min = segment duration + max(W_supply, D_production, T_transfer)
29
- *
30
- * 7-9 s on that torrent, against the 25 s in the browser today.
31
- *
32
- * Every term is measured, none is chosen, and the figures rise by themselves
33
- * when a session's interruptions grow — which is what makes lowering the buffer
34
- * safe. Pure: no torrent, no clock of its own, no state beyond the samples it
35
- * is given.
36
- */
37
-
38
- /**
39
- * How many recent interruptions are kept per file. Enough for a median to mean
40
- * something, short enough that a swarm which has recovered is not judged by how
41
- * it behaved ten minutes ago.
42
- */
43
- const SAMPLE_LIMIT = 24;
44
-
45
- /**
46
- * A reading of one interruption: how long the reader waited, and when.
47
- *
48
- * @typedef {object} Interruption
49
- * @property {number} waitedMs
50
- * @property {number} at - Epoch milliseconds when the wait ENDED.
51
- */
52
-
53
- /**
54
- * Add one interruption to a record, keeping only the recent ones.
55
- *
56
- * @param {Interruption[]} samples - Existing readings, oldest first.
57
- * @param {Interruption} interruption
58
- * @returns {Interruption[]} A new array; the input is not modified.
59
- */
60
- export function withInterruption(samples, interruption) {
61
- const kept = Array.isArray(samples) ? samples : [];
62
- if (!Number.isFinite(interruption?.waitedMs) || !Number.isFinite(interruption?.at)) {
63
- return kept;
64
- }
65
- const next = [...kept, { waitedMs: Math.max(0, interruption.waitedMs), at: interruption.at }];
66
- return next.length > SAMPLE_LIMIT ? next.slice(next.length - SAMPLE_LIMIT) : next;
67
- }
68
-
69
- /**
70
- * @param {number[]} values
71
- * @returns {number}
72
- */
73
- function median(values) {
74
- if (values.length === 0) {
75
- return 0;
76
- }
77
- const sorted = [...values].sort((left, right) => left - right);
78
- return sorted[Math.floor(sorted.length / 2)];
79
- }
80
-
81
- /**
82
- * What the recent interruptions amount to.
83
- *
84
- * The interval between interruptions is measured between the readings
85
- * themselves, so a file that is read steadily and rarely blocked reports a long
86
- * interval and asks little of the step.
87
- *
88
- * @param {Interruption[]} samples
89
- * @returns {{ samples: number, medianWaitSeconds: number, worstWaitSeconds: number, medianGapSeconds: number }}
90
- */
91
- export function summariseInterruptions(samples) {
92
- const readings = Array.isArray(samples) ? samples : [];
93
- if (readings.length === 0) {
94
- return { samples: 0, medianWaitSeconds: 0, worstWaitSeconds: 0, medianGapSeconds: 0 };
95
- }
96
- const waits = readings.map((entry) => entry.waitedMs / 1000);
97
- const gaps = [];
98
- for (let index = 1; index < readings.length; index += 1) {
99
- const gap = (readings[index].at - readings[index - 1].at) / 1000;
100
- if (gap > 0) {
101
- gaps.push(gap);
102
- }
103
- }
104
- return {
105
- samples: readings.length,
106
- medianWaitSeconds: median(waits),
107
- worstWaitSeconds: Math.max(...waits),
108
- medianGapSeconds: median(gaps)
109
- };
110
- }
111
-
112
- /**
113
- * The speed a step must run at to survive this supply, or null when the supply
114
- * has not interrupted often enough to say.
115
- *
116
- * Null is a real answer and must not be replaced by a number: with one reading
117
- * there is no interval at all, and inventing one is how a margin comes to be
118
- * chosen by hand again. A caller with no figure keeps whatever it used before
119
- * and says so.
120
- *
121
- * The worst wait is used rather than the median, because a step that only
122
- * survives the typical interruption stalls on the others — and a stall is what
123
- * the viewer sees, not an average.
124
- *
125
- * @param {{ samples: number, worstWaitSeconds: number, medianGapSeconds: number }} summary
126
- * @returns {number | null}
127
- */
128
- export function requiredSpeedFrom(summary) {
129
- if (!summary || summary.samples < 2 || !(summary.medianGapSeconds > 0)) {
130
- return null;
131
- }
132
- return 1 + summary.worstWaitSeconds / summary.medianGapSeconds;
133
- }
134
-
135
- /**
136
- * The smallest buffer at which no spinner appears: the segment being played,
137
- * whole, plus the worst interruption that can arrive before it can be refilled.
138
- *
139
- * Each term is the worst OBSERVED over a recent window, and a term nobody has
140
- * measured contributes nothing rather than a guess.
141
- *
142
- * @param {{ segmentSeconds: number, supplySeconds?: number, productionSeconds?: number, transferSeconds?: number }} terms
143
- * @returns {number}
144
- */
145
- export function minimumBufferSeconds(terms) {
146
- const segment = Number.isFinite(terms?.segmentSeconds) && terms.segmentSeconds > 0
147
- ? terms.segmentSeconds
148
- : 0;
149
- const worst = Math.max(
150
- Number.isFinite(terms?.supplySeconds) ? terms.supplySeconds : 0,
151
- Number.isFinite(terms?.productionSeconds) ? terms.productionSeconds : 0,
152
- Number.isFinite(terms?.transferSeconds) ? terms.transferSeconds : 0
153
- );
154
- return segment + Math.max(0, worst);
155
- }
@@ -1,125 +0,0 @@
1
- /**
2
- * @file The speed a step needs, and the buffer a viewer needs, from what the
3
- * supply actually did.
4
- *
5
- * The field numbers these are checked against (2026-08-17, one torrent, one
6
- * session): waits of 1.49 s median and 3.16 s worst, one every 2.22 s. The
7
- * margin chosen by hand was 1.5; the arithmetic says 1.67; the step measured
8
- * 1.05 and stalled.
9
- */
10
-
11
- import assert from "node:assert/strict";
12
- import test from "node:test";
13
-
14
- import {
15
- minimumBufferSeconds,
16
- requiredSpeedFrom,
17
- summariseInterruptions,
18
- withInterruption
19
- } from "../services/torrent-worker/supply-interruptions.js";
20
-
21
- /**
22
- * Interruptions of `waitMs`, one every `gapMs`.
23
- *
24
- * @param {number[]} waitsMs
25
- * @param {number} gapMs
26
- * @returns {Array<{ waitedMs: number, at: number }>}
27
- */
28
- function series(waitsMs, gapMs) {
29
- let samples = [];
30
- let at = 1_000_000;
31
- for (const waitedMs of waitsMs) {
32
- samples = withInterruption(samples, { waitedMs, at });
33
- at += gapMs;
34
- }
35
- return samples;
36
- }
37
-
38
- test("the field session's numbers produce the field session's answer", () => {
39
- // Waits around 1.49 s with a worst of 3.16 s, one every 2.22 s.
40
- const samples = series([1490, 1490, 3160, 1490, 1490], 2220);
41
- const summary = summariseInterruptions(samples);
42
-
43
- assert.equal(summary.worstWaitSeconds, 3.16);
44
- assert.equal(summary.medianGapSeconds, 2.22);
45
-
46
- const required = requiredSpeedFrom(summary);
47
- assert.ok(required !== null);
48
- assert.ok(
49
- Math.abs(required - 2.42) < 0.01,
50
- `1 + 3.16/2.22 = ${required?.toFixed(2)} — the supply asks for it, nobody chose it`
51
- );
52
- });
53
-
54
- test("a supply that rarely interrupts asks for almost nothing", () => {
55
- // One short wait every couple of minutes: a step barely faster than realtime
56
- // rebuilds the cushion long before the next one.
57
- const summary = summariseInterruptions(series([200, 200, 200], 120_000));
58
- const required = requiredSpeedFrom(summary);
59
- assert.ok(required !== null && required < 1.01, `asked ${required}`);
60
- });
61
-
62
- test("a supply that interrupts constantly asks for a great deal", () => {
63
- const summary = summariseInterruptions(series([4000, 4000, 4000], 1000));
64
- assert.equal(requiredSpeedFrom(summary), 5);
65
- });
66
-
67
- test("too little evidence is answered with nothing, never with a number", () => {
68
- // The whole point of deriving the margin is that it stops being invented. One
69
- // reading has no interval at all, and a caller must keep what it had.
70
- assert.equal(requiredSpeedFrom(summariseInterruptions([])), null);
71
- assert.equal(requiredSpeedFrom(summariseInterruptions(series([1000], 0))), null);
72
- assert.equal(requiredSpeedFrom(null), null);
73
- assert.equal(requiredSpeedFrom({ samples: 9, worstWaitSeconds: 3, medianGapSeconds: 0 }), null);
74
- });
75
-
76
- test("the worst wait decides, not the typical one", () => {
77
- // A step that survives the median interruption still stalls on the others,
78
- // and a stall is what the viewer sees.
79
- const summary = summariseInterruptions(series([100, 100, 5000, 100], 1000));
80
- assert.equal(summary.medianWaitSeconds, 0.1);
81
- assert.equal(summary.worstWaitSeconds, 5);
82
- assert.equal(requiredSpeedFrom(summary), 6);
83
- });
84
-
85
- test("only the recent interruptions are judged", () => {
86
- // A swarm that has recovered must not be sentenced by how it behaved ten
87
- // minutes ago, so the record is bounded.
88
- let samples = series(Array.from({ length: 40 }, () => 9000), 1000);
89
- samples = withInterruption(samples, { waitedMs: 10, at: 2_000_000 });
90
- assert.ok(samples.length <= 24, `kept ${samples.length}`);
91
- assert.equal(samples[samples.length - 1].waitedMs, 10);
92
- });
93
-
94
- test("a reading without a time or a duration is not a reading", () => {
95
- const samples = withInterruption([], { waitedMs: Number.NaN, at: 1 });
96
- assert.deepEqual(samples, []);
97
- assert.deepEqual(withInterruption([], { waitedMs: 100 }), []);
98
- });
99
-
100
- test("the minimum buffer is one segment plus the worst interruption", () => {
101
- // The field torrent: 4 s segments, a worst supply wait of 3.16 s, production
102
- // gaps within the segment length, transfer measured in milliseconds.
103
- const seconds = minimumBufferSeconds({
104
- segmentSeconds: 4,
105
- supplySeconds: 3.16,
106
- productionSeconds: 1.2,
107
- transferSeconds: 0.066
108
- });
109
- assert.ok(Math.abs(seconds - 7.16) < 0.001, `${seconds}s against the 25 s chosen by hand`);
110
- });
111
-
112
- test("whichever source is worst is the one that sizes the buffer", () => {
113
- // A step at 1.0x makes production the binding term even on a swarm that never
114
- // stalls, which is exactly the case a supply-only figure would miss.
115
- assert.equal(
116
- minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: 0.2, productionSeconds: 6 }),
117
- 10
118
- );
119
- });
120
-
121
- test("a term nobody measured contributes nothing, not a guess", () => {
122
- assert.equal(minimumBufferSeconds({ segmentSeconds: 4 }), 4);
123
- assert.equal(minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: Number.NaN }), 4);
124
- assert.equal(minimumBufferSeconds({}), 0);
125
- });