@torrent-tv/proxy 2.34.0 → 2.36.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 +11 -0
- package/package.json +1 -1
- package/server.js +8 -1
- package/services/contention.js +128 -0
- package/services/hls-session-manager.js +365 -117
- package/services/hwaccel.js +142 -28
- package/services/learned-median.js +79 -0
- package/services/torrent-cost.js +88 -0
- package/test/contention.test.js +76 -0
- package/test/decode-cost.test.js +44 -12
- package/test/learned-median.test.js +37 -0
- package/test/torrent-cost.test.js +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.36.0
|
|
2
|
+
|
|
3
|
+
- **New**: The torrent is charged for the megabytes it is measured to be moving, and the price it is charged at no longer contains work that is not the torrent's. Two faults, both visible in one field log from the addon host (2026-08-18): the same session reported **145.4 ms of CPU per MB over 8.7 MB and 23.1 ms per MB over 54 MB**, a sixfold disagreement that followed the size of the interval rather than anything about the torrent — because a process with nothing to do still runs its timers, its tunnel and its session sweeps, and that draw does not shrink when fewer megabytes move. A minimum-megabytes threshold stood against exactly this and did not hold, because a chosen number was standing in for a measured one. The draw is now measured directly, in the intervals where nothing encodes and not one byte moves, and subtracted before the rest is called the torrent's (`services/torrent-cost.js`). What the threshold was reaching for is arrived at from the readings instead: the draw's own readings disagree by a measured amount, that disagreement is worth `scatter × elapsed` seconds over an interval, and a remainder smaller than it measures the wobble in the subtraction rather than the torrent — so a small interval fails on the same arithmetic that lets a large one through, with no size chosen anywhere. The second fault: the price was then charged against the file's own byte rate — what the viewer consumes — so a fully downloaded file moving nothing still paid, and a file being fetched ahead of the viewer, which is how every session starts, paid too little. It is charged against the rate the torrent is measured to be moving, sampled every five seconds per watched torrent and divided among the files of it being read, so two episodes of one pack do not each pay for the whole download.
|
|
4
|
+
- **New**: No separate penalty for downloading, because the measurement says none is needed. The readings of 2026-08-18 have decoding at 10.1-11.2x with nothing running and 6.45-6.71x while the torrent pulled 1.8 MB/s — an extra 0.050-0.066 seconds of work per second of content, which is **28-37 ms per megabyte moved**, against the 20-35 ms/MB the host measures for itself. The download's effect on other work is the processor time it consumes, and that is already priced; unlike a second encoder, which costs 2.71× and is not explained by any sum. So this closes roadmap item 6 with a subtraction rather than another multiplier.
|
|
5
|
+
- **New**: A quality step is judged against the speed this file's own supply demands, not against a chosen margin. `1.5` (and `1.8` where decoding had no price) stood for "faster than realtime by enough", and what "enough" means is measured per file and per swarm by the reader that waits for the pieces: `1 + worst wait / median interval`, which the proxy has been printing since 2.30.0 without using. On the field torrent of 2026-08-17 that is 1.67, and on the 720p rip of the same evening 4.04-8.14 — a torrent on which no re-encoded step could have kept up, and which a fixed 1.5 admitted. Where the swarm has not been measured yet — fewer than two interruptions — the bar is realtime, which is the one thing that can be said without measuring it, and the offer is restated as soon as the reader has something to say. The refusal line names the figure it refused against and where it came from. One chosen number survives, and only where nothing can be measured: a host whose calibration produced no decode term at all is judged on an encode-only prediction that was several times optimistic in the field, and its bar stays at the 1.8 it has had since before decoding was priced, because lowering it to realtime would make the least-measured hosts the most permissive.
|
|
6
|
+
- **Chore**: Two learning thresholds removed, both of which were chosen numbers standing in for measurements. A reading of an encoder's speed is no longer withheld for the first twenty seconds of a run: each sample now carries the serial of the run it was taken from and a pair whose serials differ is discarded, which is what the wait stood in for — a restart spends up to a second and a half making its directory and burying its predecessor, and a sample taken in that window carries the old run's position, so paired with the new one it reads a twenty-minute seek as twenty minutes produced in five seconds. The wait cost every reading a short run could have given, which is how a rung spent three minutes below realtime teaching nothing (2026-08-15); the serial costs none. And a new median is adopted when it has moved further than the readings behind it disagree with each other, instead of by more than five per cent (`services/learned-median.js`). What remains chosen is the length of the history a median is taken over, and it is now written down as such rather than given a measured-sounding reason.
|
|
7
|
+
|
|
8
|
+
## 2.35.0
|
|
9
|
+
|
|
10
|
+
- **New**: A second job's cost is measured on the host instead of being added as though jobs were independent. Measured on the addon host 2026-08-18, decoding the same clip: **2.10-2.25x alone, 0.79-0.90x with one encoder beside it, 0.56-0.64x with two** — the same work costs 2.6× more for having company, and 3.7× for having two. Heat is not the cause: the hot idle machine (68 °C, a lower reported clock) was the fastest reading of all, which settles what roadmap item 6 was opened for. Four cores sharing one path to memory is the cause, and it contradicts the SHAPE of the budget rather than its constants — everything in the quality offer adds seconds of work per second of content, and these readings say two jobs that each fit alone do not fit together. So the penalty is now measured at startup the way everything else is (the cheapest clip decoded alone, then again while an encoder of it runs), and the offer multiplies a step's cost by it when anything else is encoding. Beyond the readings it holds the largest rather than extrapolating: two points say nothing about a fourth job, and a budget that guesses at a memory bottleneck will be wrong in whichever direction it guesses. With nothing measured, nothing is corrected. This is separate from the availability share of 2.33.0, which removes work nobody has been charged for; this is our own work colliding with itself.
|
|
11
|
+
|
|
1
12
|
## 2.34.0
|
|
2
13
|
|
|
3
14
|
- **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.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -37,7 +37,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
|
|
|
37
37
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
38
38
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
39
39
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
40
|
-
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, detectTonemapSupport } from "./services/hwaccel.js";
|
|
40
|
+
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, detectTonemapSupport } from "./services/hwaccel.js";
|
|
41
41
|
import { logger } from "./utils/logger.js";
|
|
42
42
|
|
|
43
43
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -130,6 +130,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
130
130
|
const decodeCostModel = videoEncoder?.kind === "software"
|
|
131
131
|
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
132
132
|
: null;
|
|
133
|
+
// What a second job costs on this host. Measured because the budget adds
|
|
134
|
+
// independent prices and this host says two jobs that each fit alone do not
|
|
135
|
+
// fit together — 2.6× on the addon box (2026-08-18).
|
|
136
|
+
const contentionPenalties = videoEncoder?.kind === "software"
|
|
137
|
+
? await benchmarkContention({ ffmpegBin, logger })
|
|
138
|
+
: null;
|
|
133
139
|
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
134
140
|
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
135
141
|
: null;
|
|
@@ -147,6 +153,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
147
153
|
videoEncoder,
|
|
148
154
|
softwarePresetBenchmark,
|
|
149
155
|
decodeCostModel,
|
|
156
|
+
contentionPenalties,
|
|
150
157
|
tonemapSupported,
|
|
151
158
|
segmentFormatId: segmentFormat,
|
|
152
159
|
stateDir,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a second job costs on this machine — measured, because it is not
|
|
3
|
+
* the sum of two prices.
|
|
4
|
+
*
|
|
5
|
+
* Measured on the addon host 2026-08-18, decoding the same clip:
|
|
6
|
+
*
|
|
7
|
+
* | what else was running | decode speed | cost |
|
|
8
|
+
* |---|---|---|
|
|
9
|
+
* | nothing (cold) | 2.10-2.25x | 0.46 s/s |
|
|
10
|
+
* | nothing (hot, 68 °C) | 2.30-2.33x | 0.43 s/s |
|
|
11
|
+
* | one encoder | 0.79-0.90x | 1.18 s/s |
|
|
12
|
+
* | two encoders | 0.56-0.64x | 1.67 s/s |
|
|
13
|
+
*
|
|
14
|
+
* The same work costs **2.6× more** with one encoder beside it and 3.7× with
|
|
15
|
+
* two. Heat is not the cause — the hot idle machine was the fastest of all.
|
|
16
|
+
* Four cores share one narrow path to memory, and decoding and encoding both
|
|
17
|
+
* saturate it.
|
|
18
|
+
*
|
|
19
|
+
* That contradicts the shape of the budget, not just its constants. Everything
|
|
20
|
+
* in the quality offer adds seconds of work per second of content — this
|
|
21
|
+
* encode, plus that decode, plus what is already committed — and these readings
|
|
22
|
+
* say two jobs that each fit alone do not fit together. The availability
|
|
23
|
+
* correction in `available-share.js` does not cover it either: that subtracts
|
|
24
|
+
* the share of the machine taken by work nobody has been charged for, while
|
|
25
|
+
* this is our own work colliding with itself.
|
|
26
|
+
*
|
|
27
|
+
* So the penalty is measured rather than derived, at startup, on the host it
|
|
28
|
+
* describes — and beyond the range that was measured it does not extrapolate:
|
|
29
|
+
* it holds the largest reading and says so, because nothing here knows what a
|
|
30
|
+
* fourth job would do.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Penalties measured on this host, keyed by how many OTHER jobs were running.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {Map<number, number>} ContentionPenalties
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The multiplier for a job's cost when `othersRunning` other jobs share the
|
|
41
|
+
* machine.
|
|
42
|
+
*
|
|
43
|
+
* @param {number} othersRunning
|
|
44
|
+
* @param {ContentionPenalties | null} measured
|
|
45
|
+
* @returns {{ penalty: number, measured: boolean, from: number }}
|
|
46
|
+
* `from` is the reading it came from — equal to `othersRunning` when one was
|
|
47
|
+
* measured for exactly that many, and the largest available otherwise.
|
|
48
|
+
*/
|
|
49
|
+
export function contentionPenalty(othersRunning, measured) {
|
|
50
|
+
const others = Number.isFinite(othersRunning) ? Math.max(0, Math.round(othersRunning)) : 0;
|
|
51
|
+
if (others === 0) {
|
|
52
|
+
// Alone on the machine is what every benchmark measures, so there is
|
|
53
|
+
// nothing to correct.
|
|
54
|
+
return { penalty: 1, measured: true, from: 0 };
|
|
55
|
+
}
|
|
56
|
+
if (!(measured instanceof Map) || measured.size === 0) {
|
|
57
|
+
// Nothing measured: the honest multiplier is 1, and the caller's own log
|
|
58
|
+
// says the prediction is uncorrected. Inventing a penalty would be the
|
|
59
|
+
// same mistake as inventing a fill rate.
|
|
60
|
+
return { penalty: 1, measured: false, from: 0 };
|
|
61
|
+
}
|
|
62
|
+
const exact = measured.get(others);
|
|
63
|
+
if (Number.isFinite(exact) && exact > 0) {
|
|
64
|
+
return { penalty: exact, measured: true, from: others };
|
|
65
|
+
}
|
|
66
|
+
// Beyond what was measured, hold the largest reading rather than continue the
|
|
67
|
+
// curve. Two readings say nothing about the shape past them, and a budget
|
|
68
|
+
// that extrapolates a memory bottleneck will be wrong in whichever direction
|
|
69
|
+
// it guesses.
|
|
70
|
+
let largestKey = 0;
|
|
71
|
+
let largestValue = 1;
|
|
72
|
+
for (const [key, value] of measured) {
|
|
73
|
+
if (key <= others && key > largestKey && Number.isFinite(value) && value > 0) {
|
|
74
|
+
largestKey = key;
|
|
75
|
+
largestValue = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (largestKey === 0) {
|
|
79
|
+
return { penalty: 1, measured: false, from: 0 };
|
|
80
|
+
}
|
|
81
|
+
return { penalty: largestValue, measured: true, from: largestKey };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A cost, corrected for what else will be running.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} costSecondsPerSecond - As the benchmarks priced it, alone.
|
|
88
|
+
* @param {number} othersRunning
|
|
89
|
+
* @param {ContentionPenalties | null} measured
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
export function costWithContention(costSecondsPerSecond, othersRunning, measured) {
|
|
93
|
+
if (!Number.isFinite(costSecondsPerSecond) || costSecondsPerSecond <= 0) {
|
|
94
|
+
return costSecondsPerSecond;
|
|
95
|
+
}
|
|
96
|
+
return costSecondsPerSecond * contentionPenalty(othersRunning, measured).penalty;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Turn measured decode speeds into penalties.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} aloneSpeed - Decode speed with the machine to itself.
|
|
103
|
+
* @param {Array<{ others: number, speed: number }>} beside - One reading per
|
|
104
|
+
* number of other jobs running.
|
|
105
|
+
* @returns {ContentionPenalties | null} Null when the alone reading is missing,
|
|
106
|
+
* since every penalty is relative to it.
|
|
107
|
+
*/
|
|
108
|
+
export function penaltiesFrom(aloneSpeed, beside) {
|
|
109
|
+
if (!Number.isFinite(aloneSpeed) || aloneSpeed <= 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const penalties = new Map();
|
|
113
|
+
for (const reading of Array.isArray(beside) ? beside : []) {
|
|
114
|
+
const others = Number.isFinite(reading?.others) ? Math.round(reading.others) : null;
|
|
115
|
+
const speed = Number(reading?.speed);
|
|
116
|
+
if (others === null || others <= 0 || !Number.isFinite(speed) || speed <= 0) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Cost is the reciprocal of speed, so the penalty is the reciprocal ratio.
|
|
120
|
+
// A job that runs at half the speed costs twice as much.
|
|
121
|
+
const penalty = aloneSpeed / speed;
|
|
122
|
+
// Below 1 would mean the machine got FASTER for being busier. It happens in
|
|
123
|
+
// the noise on a fast host, and it is not a discovery — it is a reading
|
|
124
|
+
// that says there is no penalty to measure here.
|
|
125
|
+
penalties.set(others, penalty > 1 ? penalty : 1);
|
|
126
|
+
}
|
|
127
|
+
return penalties.size > 0 ? penalties : null;
|
|
128
|
+
}
|