@torrent-tv/proxy 2.15.3 → 2.17.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 +12 -0
- package/package.json +1 -1
- package/server.js +6 -12
- package/services/hls-session-manager.js +91 -0
- package/services/host-load.js +154 -0
- package/services/hwaccel.js +1502 -1248
- package/test/encode-slope.test.js +46 -0
- package/test/host-load.test.js +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.17.0
|
|
2
|
+
|
|
3
|
+
- **New**: The encoder is benchmarked on real footage instead of a generated test pattern, and measured by ffmpeg's own progress rather than by the clock around the process. The pattern has flat areas and no grain and encodes **1.23x** cheaper than film on the same machine and preset — an error that always points at offering a rung the host cannot hold. Timing whole runs was the second error: process startup is ~0.4 s, which put `fast` and `ultrafast` within 1.24x of each other when they differ by three times. The clip is decoded once to raw frames in a temp file (feeding them through a pipe measured the pipe: the fastest presets want hundreds of megabytes a second), each preset is read from the slope between two progress reports, and the run is stopped as soon as a second of it has been covered.
|
|
4
|
+
- **Fix**: A preset that ends before its window is covered is still measured, but never over a window of no width — two reports a millisecond apart would have called a host twenty times faster than it is, and one such reading is what every ladder decision is then taken from. A position ffmpeg reports as the smallest signed 64-bit integer (some builds print that instead of `N/A` before the first packet) is discarded, and a slope above a thousand times realtime is treated as a fault rather than as a fast machine.
|
|
5
|
+
- **Fix**: Which rungs may be offered is decided from the CHEAPEST preset's throughput, not from the largest reading in the array. Measurements scatter on a busy machine — `faster` read below `fast` twice on 2026-08-15 — and taking the maximum let one noisy reading of an expensive preset raise the bar that decides what is offered. Choosing a preset still scans every entry rather than stopping at the first miss, because there the direction of that error costs picture quality, not playback.
|
|
6
|
+
- **Fix**: A host with nothing measured says so in those words — `the quality ladder is UNFILTERED on this host` — because that is what an empty benchmark means, and the previous wording said only that presets were unmeasured. The benchmark also can no longer stop the proxy from starting: a missing or read-only temp directory, or a locked file after a kill, is a host left unmeasured, not a process that fails to listen.
|
|
7
|
+
- **Fix**: The host-load line counts CPU per PROCESS across readings, and only for processes present in both. A seek kills ffmpeg and starts another whose counter begins at zero, so subtracting one total from another printed shares like `-598%`; and on a host without `/proc` the sum of no readings was reported as a confident `0%` beside honest `n/a`s.
|
|
8
|
+
|
|
9
|
+
## 2.16.0
|
|
10
|
+
|
|
11
|
+
- **New**: While an encoder runs, one line every five seconds says what the MACHINE is doing: the share of it ffmpeg is getting, the share everything else is taking, the share spent waiting on a disk, the CPU's current clock and its temperature. The budget predicts a rung from benchmarks taken at startup on an idle box, and on 2026-08-15 it predicted 1.83x for a rung that then ran at 0.90-0.999x with nothing else encoding — and no log anywhere could say which of the candidate reasons it was. Now the reading exists: an encoder starved of cores, a machine that has dropped its clock or grown hot, and work around the encode that nobody counted all look different in this line. Linux-only and best effort — a host without `/proc` writes nothing and nothing else changes.
|
|
12
|
+
|
|
1
13
|
## 2.15.3
|
|
2
14
|
|
|
3
15
|
- **Fix**: A magnet whose swarm never answered no longer poisons the film for good. It leaves a torrent with the right infohash and no file list, and WebTorrent then refuses the same film opened from a `.torrent` as a duplicate — so the answer to every later attempt came from the entry that knows nothing: `Proxy playback plan request failed (404): File index was not found in torrent`, reproduced in a browser 2026-08-15, and no reload could clear it because the useless entry outlives them all. A source that carries the metadata now replaces one that lacks it.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -124,21 +124,15 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
124
124
|
// For software libx264, benchmark preset throughput once at startup so the
|
|
125
125
|
// session manager can pick the highest-quality preset that still encodes each
|
|
126
126
|
// stream faster than realtime. Hardware encoders use their own fixed preset.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// A re-encode pays for decoding as well, and the preset benchmark measures
|
|
131
|
-
// only the encoder — which is how a rung this host runs at 0.39x came to be
|
|
132
|
-
// offered as if it cleared realtime 2.5× over (measured 2026-08-14). Solve
|
|
133
|
-
// the decode cost from the bundled calibration clips once at startup; every
|
|
134
|
-
// source is then priced from figures the probe already has.
|
|
135
|
-
// Only the software path can read it: the budget and the ladder both bail
|
|
136
|
-
// on a missing preset benchmark, and that is only produced for libx264. A
|
|
137
|
-
// host with a hardware encoder would pay three decodes at every start for a
|
|
138
|
-
// figure nothing would ever ask for.
|
|
127
|
+
// The decode model first, and the preset benchmark second — they are
|
|
128
|
+
// independent now (the presets are timed on raw frames), but the order costs
|
|
129
|
+
// nothing and keeps the two figures side by side in the log.
|
|
139
130
|
const decodeCostModel = videoEncoder?.kind === "software"
|
|
140
131
|
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
141
132
|
: null;
|
|
133
|
+
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
134
|
+
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
135
|
+
: null;
|
|
142
136
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
143
137
|
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
144
138
|
// sources on the software path when available.
|
|
@@ -18,6 +18,7 @@ import { spawn } from "node:child_process";
|
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
|
+
import { readMachineState, readProcessCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
21
22
|
|
|
22
23
|
/** Own package version, stamped onto session-start log lines. */
|
|
23
24
|
const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
@@ -1193,6 +1194,8 @@ export class HlsSessionManager {
|
|
|
1193
1194
|
* @type {Map<string, { costSec: number, version: number }>}
|
|
1194
1195
|
*/
|
|
1195
1196
|
#observedDecodeCost = new Map();
|
|
1197
|
+
/** The previous reading of the machine, to compare the next one against. */
|
|
1198
|
+
#hostLoadSample = null;
|
|
1196
1199
|
|
|
1197
1200
|
/**
|
|
1198
1201
|
* @param {HlsSessionManagerOptions} options
|
|
@@ -2641,7 +2644,95 @@ export class HlsSessionManager {
|
|
|
2641
2644
|
logger.info(`transcode ${session.id} encoder resumed — ${reason} "${session.fileName}"`);
|
|
2642
2645
|
}
|
|
2643
2646
|
|
|
2647
|
+
/**
|
|
2648
|
+
* One line per interval about the MACHINE, while an encoder is running on it.
|
|
2649
|
+
*
|
|
2650
|
+
* The budget predicts a rung from benchmarks taken at startup on an idle box,
|
|
2651
|
+
* and on 2026-08-15 it predicted 1.83x for a rung that ran at 0.90-0.999x
|
|
2652
|
+
* with nothing else encoding. Every candidate explanation is measurable — the
|
|
2653
|
+
* encoder not getting the cores, the machine having dropped its clock or
|
|
2654
|
+
* grown hot, the work around the encode costing more than anyone counted —
|
|
2655
|
+
* and none of them was being measured, so the gap could only be argued about.
|
|
2656
|
+
*
|
|
2657
|
+
* Written only while something is encoding, and only when a reading is
|
|
2658
|
+
* available: on a host without `/proc` this says nothing at all.
|
|
2659
|
+
*/
|
|
2660
|
+
async #reportHostLoad() {
|
|
2661
|
+
const encoding = [...this.sessionsById.values()].filter(
|
|
2662
|
+
(session) => session?.ffmpeg != null && !hasChildExited(session.ffmpeg) && session.state !== "disposed"
|
|
2663
|
+
);
|
|
2664
|
+
if (encoding.length === 0) {
|
|
2665
|
+
this.#hostLoadSample = null;
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
// EVERY encoder, added up. One of them is meaningless on a host that runs a
|
|
2669
|
+
// picture and an audio track at once, and picking the first would have
|
|
2670
|
+
// reported whichever the map happened to hold.
|
|
2671
|
+
// Kept per PROCESS, not as one total. The set changes between readings —
|
|
2672
|
+
// a seek kills ffmpeg and starts another with a new pid whose counter
|
|
2673
|
+
// begins at zero, a session ends, a rendition begins — and subtracting one
|
|
2674
|
+
// total from another across a changed set produces nonsense: a restart
|
|
2675
|
+
// alone would print something like `ffmpeg=-598%`. Only pids present in
|
|
2676
|
+
// BOTH readings are counted, so a process that came or went contributes
|
|
2677
|
+
// nothing rather than a lie.
|
|
2678
|
+
const pids = encoding.map((session) => session.ffmpeg?.pid ?? null).filter((pid) => pid !== null);
|
|
2679
|
+
const [system, ...cpuReadings] = await Promise.all([
|
|
2680
|
+
readSystemCpu(),
|
|
2681
|
+
...pids.map((pid) => readProcessCpuSeconds(pid))
|
|
2682
|
+
]);
|
|
2683
|
+
/** @type {Map<number, number>} */
|
|
2684
|
+
const byPid = new Map();
|
|
2685
|
+
pids.forEach((pid, index) => {
|
|
2686
|
+
const seconds = cpuReadings[index];
|
|
2687
|
+
if (seconds !== null) {
|
|
2688
|
+
byPid.set(pid, seconds);
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2691
|
+
const sample = { takenAt: Date.now(), byPid, system };
|
|
2692
|
+
const previous = this.#hostLoadSample;
|
|
2693
|
+
this.#hostLoadSample = sample;
|
|
2694
|
+
if (previous === null) {
|
|
2695
|
+
return; // the first reading is only something to compare against
|
|
2696
|
+
}
|
|
2697
|
+
// Summed over the pids both readings hold, so nothing is measured against a
|
|
2698
|
+
// process that was not there before. Unknown stays unknown: on a host with
|
|
2699
|
+
// no `/proc` there are no readings at all, and the share is null rather
|
|
2700
|
+
// than a confident zero.
|
|
2701
|
+
let encoderDelta = null;
|
|
2702
|
+
for (const [pid, seconds] of sample.byPid) {
|
|
2703
|
+
const before = previous.byPid?.get(pid);
|
|
2704
|
+
if (before !== undefined && seconds >= before) {
|
|
2705
|
+
encoderDelta = (encoderDelta ?? 0) + (seconds - before);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
const share = shareOfMachine(
|
|
2709
|
+
{ takenAt: previous.takenAt, processCpuSeconds: encoderDelta === null ? null : 0, system: previous.system },
|
|
2710
|
+
{ takenAt: sample.takenAt, processCpuSeconds: encoderDelta, system: sample.system }
|
|
2711
|
+
);
|
|
2712
|
+
if (share === null) {
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
// How many of them are stopped by the look-ahead cap. Without this a zero
|
|
2716
|
+
// share reads as an encoder being starved of the machine, when it is an
|
|
2717
|
+
// encoder deliberately not running — which is what the first readings on
|
|
2718
|
+
// the addon host actually were (2026-08-15: `ffmpeg=0% system=24%`, both
|
|
2719
|
+
// encoders suspended, and the speed beside it a stale figure from before
|
|
2720
|
+
// they stopped).
|
|
2721
|
+
const suspended = encoding.filter((session) => session.encoderPaused === true).length;
|
|
2722
|
+
const running = encoding.length - suspended;
|
|
2723
|
+
const machine = await readMachineState();
|
|
2724
|
+
const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
|
|
2725
|
+
logger.info(
|
|
2726
|
+
`host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
|
|
2727
|
+
`iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
|
|
2728
|
+
`temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
|
|
2729
|
+
`encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
|
|
2730
|
+
` over=${share.elapsedSec.toFixed(1)}s`
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2644
2734
|
async #enforceRealtimeBudget() {
|
|
2735
|
+
void this.#reportHostLoad();
|
|
2645
2736
|
if (this.videoEncoder?.kind !== "software") {
|
|
2646
2737
|
return;
|
|
2647
2738
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the machine is doing while an encoder runs on it.
|
|
3
|
+
*
|
|
4
|
+
* The budget predicts a rung from two benchmarks taken at startup on an idle
|
|
5
|
+
* machine. On 2026-08-15 it predicted 1.83x for a rung that then ran at
|
|
6
|
+
* 0.90-0.999x with nothing else encoding, and nothing in any log said why. The
|
|
7
|
+
* candidates are all measurable and none of them was being measured:
|
|
8
|
+
*
|
|
9
|
+
* - the encoder is not getting the cores (something else is taking them, or
|
|
10
|
+
* it is waiting on input);
|
|
11
|
+
* - the machine is no longer the machine that was benchmarked, because it has
|
|
12
|
+
* dropped its clock or grown hot;
|
|
13
|
+
* - the work around the encode — hashing pieces, serving segments — costs
|
|
14
|
+
* more than anyone counted.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is Linux-specific and best effort: a host without these files
|
|
17
|
+
* reports nulls and nothing above it changes. The proxy stays
|
|
18
|
+
* deployment-agnostic (`../CLAUDE.md`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clock ticks per second, the unit `/proc/<pid>/stat` counts CPU time in.
|
|
26
|
+
* `getconf CLK_TCK` is 100 on every Linux this runs on; spawning a process to
|
|
27
|
+
* ask would cost more than the measurement.
|
|
28
|
+
*/
|
|
29
|
+
const CLOCK_TICKS_PER_SECOND = 100;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} CpuTotals
|
|
33
|
+
* @property {number} busySeconds - Everything but idle.
|
|
34
|
+
* @property {number} idleSeconds
|
|
35
|
+
* @property {number} iowaitSeconds - Idle because it is waiting for a disk.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The system's CPU time since boot, from `/proc/stat`.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<CpuTotals | null>}
|
|
42
|
+
*/
|
|
43
|
+
export async function readSystemCpu() {
|
|
44
|
+
try {
|
|
45
|
+
const text = await readFile("/proc/stat", "utf8");
|
|
46
|
+
const line = text.split("\n", 1)[0];
|
|
47
|
+
if (!line.startsWith("cpu ")) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const fields = line.trim().split(/\s+/).slice(1).map(Number);
|
|
51
|
+
if (fields.length < 5 || fields.some((value) => !Number.isFinite(value))) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const [user, nice, system, idle, iowait] = fields;
|
|
55
|
+
const busyTicks = user + nice + system + fields.slice(5).reduce((sum, value) => sum + value, 0);
|
|
56
|
+
return {
|
|
57
|
+
busySeconds: busyTicks / CLOCK_TICKS_PER_SECOND,
|
|
58
|
+
idleSeconds: idle / CLOCK_TICKS_PER_SECOND,
|
|
59
|
+
iowaitSeconds: iowait / CLOCK_TICKS_PER_SECOND
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return null; // not Linux, or /proc is not mounted
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The CPU time one process has used, from `/proc/<pid>/stat`.
|
|
68
|
+
*
|
|
69
|
+
* The comm field can itself contain spaces and brackets, so the fields are
|
|
70
|
+
* counted from the LAST `)` rather than by splitting the whole line — the usual
|
|
71
|
+
* trap with this file.
|
|
72
|
+
*
|
|
73
|
+
* @param {number} pid
|
|
74
|
+
* @returns {Promise<number | null>} Seconds of CPU, or null.
|
|
75
|
+
*/
|
|
76
|
+
export async function readProcessCpuSeconds(pid) {
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const text = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
82
|
+
const afterComm = text.slice(text.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
83
|
+
// After the comm and state fields, utime is index 11 and stime index 12.
|
|
84
|
+
const utime = Number(afterComm[11]);
|
|
85
|
+
const stime = Number(afterComm[12]);
|
|
86
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return (utime + stime) / CLOCK_TICKS_PER_SECOND;
|
|
90
|
+
} catch {
|
|
91
|
+
return null; // the process is gone, or this is not Linux
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Is this still the machine the benchmarks were taken on: its clock and its
|
|
97
|
+
* temperature.
|
|
98
|
+
*
|
|
99
|
+
* @returns {Promise<{ megahertz: number | null, celsius: number | null }>}
|
|
100
|
+
*/
|
|
101
|
+
export async function readMachineState() {
|
|
102
|
+
const [frequency, temperature] = await Promise.all([
|
|
103
|
+
readFile("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", "utf8").catch(() => null),
|
|
104
|
+
readFile("/sys/class/thermal/thermal_zone0/temp", "utf8").catch(() => null)
|
|
105
|
+
]);
|
|
106
|
+
const kilohertz = frequency === null ? Number.NaN : Number(frequency.trim());
|
|
107
|
+
const milliCelsius = temperature === null ? Number.NaN : Number(temperature.trim());
|
|
108
|
+
return {
|
|
109
|
+
megahertz: Number.isFinite(kilohertz) ? Math.round(kilohertz / 1000) : null,
|
|
110
|
+
celsius: Number.isFinite(milliCelsius) ? Math.round(milliCelsius / 100) / 10 : null
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Turn two readings into the shares of ONE second of wall clock that each part
|
|
116
|
+
* of the machine spent working. A share is per whole machine: 1.0 means every
|
|
117
|
+
* core was busy for the whole interval.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }} before
|
|
120
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }} after
|
|
121
|
+
* @param {number} [cores=os.cpus().length]
|
|
122
|
+
* @returns {{ elapsedSec: number, processShare: number | null, systemShare: number | null, iowaitShare: number | null } | null}
|
|
123
|
+
*/
|
|
124
|
+
export function shareOfMachine(before, after, cores = os.cpus().length) {
|
|
125
|
+
const elapsedSec = (after.takenAt - before.takenAt) / 1000;
|
|
126
|
+
if (!(elapsedSec > 0) || !(cores > 0)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const machineSeconds = elapsedSec * cores;
|
|
130
|
+
const processShare = before.processCpuSeconds !== null && after.processCpuSeconds !== null
|
|
131
|
+
? (after.processCpuSeconds - before.processCpuSeconds) / machineSeconds
|
|
132
|
+
: null;
|
|
133
|
+
const systemShare = before.system !== null && after.system !== null
|
|
134
|
+
? (after.system.busySeconds - before.system.busySeconds) / machineSeconds
|
|
135
|
+
: null;
|
|
136
|
+
const iowaitShare = before.system !== null && after.system !== null
|
|
137
|
+
? (after.system.iowaitSeconds - before.system.iowaitSeconds) / machineSeconds
|
|
138
|
+
: null;
|
|
139
|
+
return { elapsedSec, processShare, systemShare, iowaitShare };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One reading of everything above, to be compared against the next.
|
|
144
|
+
*
|
|
145
|
+
* @param {number | null} pid - The encoder's process, when one is running.
|
|
146
|
+
* @returns {Promise<{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }>}
|
|
147
|
+
*/
|
|
148
|
+
export async function sampleHost(pid) {
|
|
149
|
+
const [processCpuSeconds, system] = await Promise.all([
|
|
150
|
+
pid === null ? Promise.resolve(null) : readProcessCpuSeconds(pid),
|
|
151
|
+
readSystemCpu()
|
|
152
|
+
]);
|
|
153
|
+
return { takenAt: Date.now(), processCpuSeconds, system };
|
|
154
|
+
}
|