@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.15.3",
3
+ "version": "2.17.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": {
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
- const softwarePresetBenchmark = videoEncoder?.kind === "software"
128
- ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
129
- : null;
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
+ }