@torrent-tv/proxy 2.12.2 → 2.13.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 +18 -0
- package/assets/calibration/NOTICE.md +30 -0
- package/assets/calibration/cal-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-720.mp4 +0 -0
- package/bin/cli.js +6 -1
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +7 -0
- package/server.js +22 -3
- package/services/ffmpeg-banner.js +42 -0
- package/services/hls-session-manager.js +461 -20
- package/services/hwaccel.js +478 -12
- package/services/playback-planner.js +36 -3
- package/test/decode-cost.test.js +401 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 2.13.0
|
|
2
|
+
|
|
3
|
+
- **New**: A quality rung is offered only where this host can produce it faster than it is watched, and the budget now knows what DECODING costs. It priced the encoder alone, which is half the work — a re-encode decodes the whole source first — so on the addon host the startup benchmark read 11.2 Mpx/s against the 2.45 Mpx/s a 240p rung needs, declared it clear by two and a half times, and the rung then ran at 0.388-0.947x: first segment 30 099 ms, later segments held 21 951 ms and 10 662 ms, while the 1080p it replaced was being COPIED at 7.8-8.9x. Choosing a lower quality was what broke playback. Three bundled clips (`assets/calibration/`, cut from Netflix Open Content "Meridian", CC-BY 4.0) are decoded once at startup and solve this host's cost as `a × Mpixel/s + b × Mbit/s + c`; a rung is then priced as `1/(1/decodeSpeed + 1/encodeSpeed)` and left out of the master playlist unless it clears realtime by the margin. Checked against a file the fit was not made from: 4.8 % error, where the encoder-only model was 209 % out on that rung. Real footage rather than a generated pattern, because `testsrc2` decodes 158 % away from a real film where these clips are 11 % away.
|
|
4
|
+
- **New**: The playback plan carries the heights this host could serve the file at, for both branches — copied video and re-encoded — so the viewer's quality menu is right from the moment a file is opened rather than from the moment an encoder exists. Only the browser knows which branch it will take, so both are answered; a session that then runs replaces the estimate with what its encoder really does.
|
|
5
|
+
- **Chore**: The decode measurement reads ffmpeg's own progress rather than the clock around the process. Starting ffmpeg costs about a second, and on a quick machine a five-second clip decodes in a tenth of that — so timing the process measured the process starting, and working around it by repeating passes and subtracting took 20-26 s and still produced a fit that had to be rejected. Progress lines arrive twice a second after startup, and the slope between two of them contains no part of it by construction: one run per clip, stopped as soon as a second of decoding has been observed. Measured on a desktop: 7.4 s and 7.9 s for the whole benchmark on two runs, agreeing to 5 %, where the differencing method gave anything between "no model" and a fivefold spread.
|
|
6
|
+
- **New**: A session opened at a named height starts no higher than this host can hold. The height comes from a browser that was told what is on offer, but a stale tab or a repeated address can still name a rung that was refused, and starting there means the encode never catches up — the runtime downshift would step down eventually, and the viewer would watch it happen. The ladder beneath the request is kept, as before.
|
|
7
|
+
- **New**: The session-create response carries `offeredHeights`, the heights this host will actually serve the file at. A stream without variants still changes quality by re-opening the session, and the browser was composing that list itself from the source height and a fixed ladder — a statement about the file where the question is about the host.
|
|
8
|
+
- **New**: What a source really costs to decode is learned from the encoder running on it, and replaces the estimate made from the startup clips. A re-encode pays for both halves and ffmpeg reports the sum, so subtracting the encode half — priced for the preset and pixel rate actually in use — leaves this file's own decoding, on this machine, under whatever else it is doing. The clips are H.264 while a source that needs re-encoding usually is not, which is exactly where the model was optimistic: on the field case of 2026-08-14 the clips priced that film's 240p rung at 1.58x and admitted it, while one reading of the rung itself prices it at 0.95x and refuses it. The fastest reading is kept rather than the latest, since a slow moment can be a starved download rather than a slow host. It also prices a host whose clips were never fitted, which until now could refuse nothing.
|
|
9
|
+
- **Fix**: The rung a viewer is WATCHING is never withdrawn from the offer. The list is recomputed as the host learns, and the reading that teaches it comes from the rung just switched to — so the rung that taught the lesson would be the first dropped, and every route guard reads that list: the next segment of a playing stream would 404 with its own encoder still running. It leaves the offer when the viewer leaves it.
|
|
10
|
+
- **New**: The progress response carries the rungs still on offer, restated about once a second. The menu the viewer sees is corrected as the host learns what this source costs, so a rung beyond the machine disappears from it instead of being found by switching to it.
|
|
11
|
+
- **Fix**: The decode cost is priced from the VIDEO stream's bitrate, not the container's. The calibration clips carry video alone and are decoded with `-an`, so the fitted term describes video bits, while the container figure adds every audio and subtitle track — a release with two or three AC-3/DTS tracks carries 1-2 Mbit/s of them, which inflated the predicted cost by 10-25 % and refused rungs on the strength of audio the benchmark never decoded. The term is also not the weak one it was recorded as: on the shipped clips an 11.7× bitrate change moves the cost 2.47×, and it is about two thirds of the predicted cost of a high-bitrate 1080p source.
|
|
12
|
+
- **Fix**: What a file's offer is computed from is answered on every response instead of being frozen into the cached plan — the defect fixed once in 2.9.106, in the same object and three lines under the comment recording it. A plan is cached for the life of the process while what the host will serve is not, so every later open of a file handed back the first guess and undid what the encoder had learned.
|
|
13
|
+
- **Fix**: A downshift chooses its preset with decoding priced, as the offer and the starting rung already did. Choosing it on the encoder alone treats decoding as free, which is what made the check and the encode disagree — and it mattered most here, on a host that has already failed to keep up and is spending one of its three downshifts.
|
|
14
|
+
- **New**: `--state-dir` says where to keep what this host has measured about itself; without it the file stays beside the installed proxy, exactly where it has always been. Deployments differ in what survives: on the Home Assistant addon both the install directory and the working directory sit in the container's writable layer and are discarded when an update rebuilds it, so only a directory the host keeps — `/data` there — makes the measurements outlive an update. Which directory that is cannot be decided here without putting one deployment's assumptions into proxy code, so the deployment names it.
|
|
15
|
+
- **Chore**: The margin is 1.5 where the prediction includes decoding, and stays 1.8 where it cannot. An encoder-only figure was several times too optimistic on the rung this exists for, so it is not fit to refuse anything: a host with no usable fit offers the whole ladder exactly as before, and its preset is still chosen against the old 1.8.
|
|
16
|
+
- **Chore**: Where the three-point fit produces a negative term the bitrate term is dropped and the remaining two are fitted by least squares over all three points. A negative term does not describe a host; it says the difference it was solved from is inside the noise between runs, which is what a fast machine produces — measured on a desktop, the 720p clip took longer per second of video than the low-bitrate 1080p one, because process startup is a large share of a decode lasting a second. If even the pixel slope comes out non-positive there is no measurable dependence on the source, and the fit is refused rather than invented.
|
|
17
|
+
- **Known limits**, both measured rather than assumed: the prediction describes an idle machine, so on the very host above the 240p rung predicts 1.58x and clears a margin of 1.5 while the field measured it at 0.388-0.947x under real load (copying 1080p, downloading, serving) — the margin is what has to carry that, and 1.5 does not. And the fit is made from H.264 clips, so it describes H.264 decoding: a source that must be re-encoded is by definition not H.264, and HEVC or AV1 decode dearer per pixel on the same box.
|
|
18
|
+
|
|
1
19
|
## 2.12.2
|
|
2
20
|
|
|
3
21
|
- **Fix**: The transport heartbeat is written once per connection, with each channel's queue beside it. The counters it reports belong to the peer connection, not to a channel, so printing the line per channel produced two byte-for-byte identical readings — `sent=5153491` under both "proxy" and "proxy-control" on 2026-08-14 — which read as two independent measurements agreeing. The one figure that IS per channel, its queue depth, was the only real difference and was buried in a line that looked like a duplicate, leaving the second channel unobservable in the log.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Calibration clips — attribution
|
|
2
|
+
|
|
3
|
+
The three clips in this folder are excerpts from **"Meridian"**, part of
|
|
4
|
+
[Netflix Open Content](https://opencontent.netflix.com/), licensed under the
|
|
5
|
+
**Creative Commons Attribution 4.0 International (CC BY 4.0)** licence:
|
|
6
|
+
<https://creativecommons.org/licenses/by/4.0/>.
|
|
7
|
+
|
|
8
|
+
They were cut from `Meridian/Meridian_UHD4k5994_HDR_P3PQ.mp4` and re-encoded to
|
|
9
|
+
H.264 High, 24 fps, 5.04 s (121 frames), no audio:
|
|
10
|
+
|
|
11
|
+
| file | resolution | bitrate |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `cal-1080-hi.mp4` | 1920×1080 | 11.38 Mbit/s |
|
|
14
|
+
| `cal-1080-lo.mp4` | 1920×1080 | 0.97 Mbit/s |
|
|
15
|
+
| `cal-720.mp4` | 1280×720 | 2.25 Mbit/s |
|
|
16
|
+
|
|
17
|
+
## Why these three, and why real footage
|
|
18
|
+
|
|
19
|
+
`services/hwaccel.js` decodes them at startup and solves the host's decode cost,
|
|
20
|
+
`a × Mpixel/s + b × Mbit/s + c`, from the three measurements. The first two
|
|
21
|
+
clips share a pixel count and differ 11.7× in bitrate; the third changes the
|
|
22
|
+
pixel count. Three points, three unknowns.
|
|
23
|
+
|
|
24
|
+
Real, grainy live action rather than a generated pattern: measured 2026-08-14 on
|
|
25
|
+
the addon host, this material decodes 11 % away from the film being served,
|
|
26
|
+
where a generated `testsrc2` clip is 158 % away.
|
|
27
|
+
|
|
28
|
+
Replacing a clip is allowed — the benchmark reads each clip's dimensions, frame
|
|
29
|
+
rate and bitrate from ffmpeg's own output rather than from this table — but the
|
|
30
|
+
three must keep spanning the two axes, or the fit has nothing to separate.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/bin/cli.js
CHANGED
|
@@ -76,6 +76,10 @@ program
|
|
|
76
76
|
.option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
|
|
77
77
|
.option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
|
|
78
78
|
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
79
|
+
.option(
|
|
80
|
+
"--state-dir <path>",
|
|
81
|
+
"Where to keep what this host has measured about itself (default: beside the installed proxy)"
|
|
82
|
+
)
|
|
79
83
|
.option(
|
|
80
84
|
"--segment-format <format>",
|
|
81
85
|
`HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
|
|
@@ -273,7 +277,8 @@ try {
|
|
|
273
277
|
ffmpegBin,
|
|
274
278
|
maxDiskBytes,
|
|
275
279
|
memoryBytes,
|
|
276
|
-
segmentFormat: options.segmentFormat
|
|
280
|
+
segmentFormat: options.segmentFormat,
|
|
281
|
+
stateDir: options.stateDir
|
|
277
282
|
});
|
|
278
283
|
app = started.app;
|
|
279
284
|
actualPort = started.port;
|
package/package.json
CHANGED
|
@@ -129,6 +129,13 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
129
129
|
variantHeight: hlsSessionManager.variantHeightOf(session)
|
|
130
130
|
}
|
|
131
131
|
: {}),
|
|
132
|
+
// The heights this host will actually serve this file at, largest first
|
|
133
|
+
// — the ladder minus every rung it cannot produce faster than it is
|
|
134
|
+
// watched. The browser needs it whether or not a master exists: without
|
|
135
|
+
// it, it fell back to a ladder of its own invention and offered rungs the
|
|
136
|
+
// proxy had just refused, and picking one re-opened the session at a
|
|
137
|
+
// height measured at a third of realtime.
|
|
138
|
+
offeredHeights: hlsSessionManager.offeredHeights(session),
|
|
132
139
|
// What this session's output will carry, stated rather than left to be
|
|
133
140
|
// discovered. The browser checks what it actually got against this: a
|
|
134
141
|
// track that never arrives is otherwise noticed only by its absence,
|
package/server.js
CHANGED
|
@@ -35,7 +35,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
|
|
|
35
35
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
36
36
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
37
37
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
38
|
-
import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
|
|
38
|
+
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, detectTonemapSupport } from "./services/hwaccel.js";
|
|
39
39
|
import { logger } from "./utils/logger.js";
|
|
40
40
|
|
|
41
41
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -68,6 +68,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
68
68
|
* @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
|
|
69
69
|
* @property {number} [memoryBytes] - Per-torrent budget for pieces held in memory (undefined = store default).
|
|
70
70
|
* @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
|
|
71
|
+
* @property {string} [stateDir] - Where to keep what this host has measured about itself.
|
|
71
72
|
*/
|
|
72
73
|
|
|
73
74
|
/**
|
|
@@ -76,7 +77,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
76
77
|
* @param {ProxyServerOptions} options
|
|
77
78
|
* @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
|
|
78
79
|
*/
|
|
79
|
-
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat }) {
|
|
80
|
+
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir }) {
|
|
80
81
|
const app = Fastify({
|
|
81
82
|
// No practical body-size limit — the proxy server is localhost-only and
|
|
82
83
|
// receives torrent source payloads that may be arbitrarily large.
|
|
@@ -124,6 +125,18 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
124
125
|
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
125
126
|
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
126
127
|
: null;
|
|
128
|
+
// A re-encode pays for decoding as well, and the preset benchmark measures
|
|
129
|
+
// only the encoder — which is how a rung this host runs at 0.39x came to be
|
|
130
|
+
// offered as if it cleared realtime 2.5× over (measured 2026-08-14). Solve
|
|
131
|
+
// the decode cost from the bundled calibration clips once at startup; every
|
|
132
|
+
// source is then priced from figures the probe already has.
|
|
133
|
+
// Only the software path can read it: the budget and the ladder both bail
|
|
134
|
+
// on a missing preset benchmark, and that is only produced for libx264. A
|
|
135
|
+
// host with a hardware encoder would pay three decodes at every start for a
|
|
136
|
+
// figure nothing would ever ask for.
|
|
137
|
+
const decodeCostModel = videoEncoder?.kind === "software"
|
|
138
|
+
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
139
|
+
: null;
|
|
127
140
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
128
141
|
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
129
142
|
// sources on the software path when available.
|
|
@@ -137,8 +150,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
137
150
|
localPort: selectedPort,
|
|
138
151
|
videoEncoder,
|
|
139
152
|
softwarePresetBenchmark,
|
|
153
|
+
decodeCostModel,
|
|
140
154
|
tonemapSupported,
|
|
141
155
|
segmentFormatId: segmentFormat,
|
|
156
|
+
stateDir,
|
|
142
157
|
// Live download stats accessor for the realtime budget: lets it tell a
|
|
143
158
|
// CPU-bound transcode from a download-starved input before downscaling.
|
|
144
159
|
getSourceStats: async (sourceKey, fileIndex) => {
|
|
@@ -168,7 +183,11 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
168
183
|
torrentPool,
|
|
169
184
|
warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
|
|
170
185
|
expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs(),
|
|
171
|
-
expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs()
|
|
186
|
+
expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs(),
|
|
187
|
+
// The quality menu is on screen from the moment a file is opened, so the
|
|
188
|
+
// heights this host can actually serve have to be answerable before any
|
|
189
|
+
// encoder exists — from the probe and the startup benchmarks alone.
|
|
190
|
+
predictOfferedHeights: (mediaInfo) => hlsSessionManager.predictOfferedHeights(mediaInfo)
|
|
172
191
|
});
|
|
173
192
|
|
|
174
193
|
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
@@ -54,6 +54,48 @@ export function parseFfmpegStartTimeSeconds(stderrText) {
|
|
|
54
54
|
return Number.isFinite(value) ? value : 0;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Parse the bitrate (kbit/s) that the decode cost is priced from: the VIDEO
|
|
59
|
+
* stream's own, falling back to the container's when the stream does not state
|
|
60
|
+
* one. Returns null when neither is present.
|
|
61
|
+
*
|
|
62
|
+
* The distinction is not cosmetic. The calibration clips carry video alone and
|
|
63
|
+
* are decoded with `-an`, so the fitted bitrate term describes VIDEO bits; the
|
|
64
|
+
* container figure adds every audio and subtitle track. A Russian release with
|
|
65
|
+
* two or three AC-3/DTS tracks carries 1-2 Mbit/s of audio, and on this host's
|
|
66
|
+
* own fit that inflates the predicted decode cost by 10-25 % — refusing rungs
|
|
67
|
+
* on the strength of audio the benchmark never decoded. And the term is not the
|
|
68
|
+
* weak one it was once described as: on the shipped clips an 11.7× bitrate
|
|
69
|
+
* change moved the cost 2.47×, and it accounts for about two thirds of the
|
|
70
|
+
* predicted cost of a high-bitrate 1080p source.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} stderrText
|
|
73
|
+
* @returns {number | null}
|
|
74
|
+
*/
|
|
75
|
+
export function parseFfmpegBitrateKbps(stderrText) {
|
|
76
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// `Stream #0:0 … Video: h264 … 11375 kb/s, 24 fps` — the stream's own rate,
|
|
80
|
+
// stated per stream and therefore free of the other tracks. Only the INPUT
|
|
81
|
+
// section is read: everything from "Stream mapping:" onwards describes what
|
|
82
|
+
// ffmpeg is about to produce, and that line carries a bitrate of its own.
|
|
83
|
+
const inputSection = stderrText.split(/^Stream mapping:/m)[0];
|
|
84
|
+
const perStream = inputSection.match(/Stream\s+#[^\n]*?Video:[^\n]*?,\s*(\d+)\s*kb\/s/i);
|
|
85
|
+
if (perStream) {
|
|
86
|
+
const streamValue = Number(perStream[1]);
|
|
87
|
+
if (Number.isFinite(streamValue) && streamValue > 0) {
|
|
88
|
+
return streamValue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const match = stderrText.match(/Duration:[^\n]*?bitrate:\s*(\d+)\s*kb\/s/i);
|
|
92
|
+
if (!match) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const value = Number(match[1]);
|
|
96
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
57
99
|
/**
|
|
58
100
|
* Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
|
|
59
101
|
* … WxH" line). Returns `{ width: null, height: null }` when absent.
|