@torrent-tv/proxy 2.15.2 → 2.16.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,11 @@
1
+ ## 2.16.0
2
+
3
+ - **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.
4
+
5
+ ## 2.15.3
6
+
7
+ - **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.
8
+
1
9
  ## 2.15.2
2
10
 
3
11
  - **Fix**: A segment request that can never be answered is answered as absent instead of being held for a minute. Changing audio track makes hls.js ask the new stream for segment #0 before anything else; the run was at #354, the repair reaches sixty segments back and no further, no seek was coming, and an encoder only moves forward — so the request was unanswerable from the moment it arrived, and holding it simply spent the player's own patience. Measured 2026-08-15: the track was made ready in 7.1 s at the viewer's position, and the viewer then watched a spinner for **63 s** — sixty of them the hold, the rest the player recovering after it failed. Deliberately narrower than the refusal 2.14.1 shipped and 2.14.2 withdrew: a request within the repair's reach, or one with a seek on its way, is still held, because for those the encoder is about to be moved there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.15.2",
3
+ "version": "2.16.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": {
@@ -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, sampleHost, 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,53 @@ 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
+ // The busiest single encoder is the one worth naming; the system share
2669
+ // below covers everything else the box is doing.
2670
+ const watched = encoding[0];
2671
+ const sample = await sampleHost(watched.ffmpeg?.pid ?? null);
2672
+ const previous = this.#hostLoadSample;
2673
+ this.#hostLoadSample = sample;
2674
+ if (previous === null) {
2675
+ return; // the first reading is only something to compare against
2676
+ }
2677
+ const share = shareOfMachine(previous, sample);
2678
+ if (share === null) {
2679
+ return;
2680
+ }
2681
+ const machine = await readMachineState();
2682
+ const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
2683
+ logger.info(
2684
+ `host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
2685
+ `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
2686
+ `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
2687
+ `encoders=${encoding.length} speed=${watched.progress?.speed ?? "n/a"} ` +
2688
+ `over=${share.elapsedSec.toFixed(1)}s`
2689
+ );
2690
+ }
2691
+
2644
2692
  async #enforceRealtimeBudget() {
2693
+ void this.#reportHostLoad();
2645
2694
  if (this.videoEncoder?.kind !== "software") {
2646
2695
  return;
2647
2696
  }
@@ -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
+ }
@@ -438,6 +438,17 @@ export function fileDownloadedBytes(torrent, file) {
438
438
  * File-level piece selection is tracked through a reference-count map so
439
439
  * that only files with at least one active stream cause downloading.
440
440
  */
441
+ /**
442
+ * Whether what we are about to add is a magnet — a name for content, with no
443
+ * file list in it — rather than a .torrent, which carries one.
444
+ *
445
+ * @param {string | Buffer} torrentId
446
+ * @returns {boolean}
447
+ */
448
+ function isMagnetSource(torrentId) {
449
+ return typeof torrentId === "string" && /^magnet:\?/i.test(torrentId.trim());
450
+ }
451
+
441
452
  export class TorrentPool {
442
453
  /**
443
454
  * In-flight `client.add()` promises keyed by the same key as `torrents`.
@@ -989,6 +1000,25 @@ export class TorrentPool {
989
1000
  const dupMatch = /duplicate torrent ([0-9a-f]{40})/i.exec(message);
990
1001
  if (dupMatch) {
991
1002
  const existing = this.client.torrents.find((t) => t?.infoHash === dupMatch[1]);
1003
+ // A torrent already here but WITHOUT metadata is not an answer to a
1004
+ // request that carries metadata. A magnet whose swarm never answered
1005
+ // leaves exactly that: an entry with the right infohash, no file
1006
+ // list, and no way to get one. The same film opened from a .torrent
1007
+ // then collides with it and is refused as a duplicate — measured
1008
+ // 2026-08-15 in a browser, "File index was not found in torrent", and
1009
+ // no reload could ever fix it because the poisoned entry outlives
1010
+ // every attempt. The one that knows the files wins.
1011
+ if (existing && !existing.ready && !isMagnetSource(torrentId)) {
1012
+ logger.info(
1013
+ `torrent-pool: replacing ${dupMatch[1].slice(0, 8)} — it was added from a magnet that never ` +
1014
+ "found peers, and this source carries the metadata it lacks"
1015
+ );
1016
+ existing.destroy({ destroyStore: false }, () => {
1017
+ this.#pending.delete(key);
1018
+ resolve(this.getTorrent(sourceType, source));
1019
+ });
1020
+ return;
1021
+ }
992
1022
  if (existing) {
993
1023
  const settle = () => {
994
1024
  this.torrents.set(key, existing);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file The arithmetic that turns two readings of the machine into shares.
3
+ *
4
+ * The readings themselves are files this host may or may not have; what is
5
+ * tested here is what is DERIVED from them, because that is where a wrong
6
+ * number would quietly become a wrong conclusion about why an encoder is slow.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import { readProcessCpuSeconds, readSystemCpu, sampleHost, shareOfMachine } from "../services/host-load.js";
13
+
14
+ test("a process using one core of four for a second reports a quarter of the machine", () => {
15
+ const before = { takenAt: 1_000, processCpuSeconds: 10, system: null };
16
+ const after = { takenAt: 2_000, processCpuSeconds: 11, system: null };
17
+ const share = shareOfMachine(before, after, 4);
18
+ assert.equal(share?.elapsedSec, 1);
19
+ assert.equal(share?.processShare, 0.25);
20
+ });
21
+
22
+ test("a process using every core reports the whole machine", () => {
23
+ const share = shareOfMachine(
24
+ { takenAt: 0, processCpuSeconds: 0, system: null },
25
+ { takenAt: 2_000, processCpuSeconds: 8, system: null },
26
+ 4
27
+ );
28
+ assert.equal(share?.processShare, 1);
29
+ });
30
+
31
+ test("waiting for a disk is counted apart from working", () => {
32
+ const before = { takenAt: 0, processCpuSeconds: null, system: { busySeconds: 100, idleSeconds: 900, iowaitSeconds: 10 } };
33
+ const after = { takenAt: 1_000, processCpuSeconds: null, system: { busySeconds: 101, idleSeconds: 902, iowaitSeconds: 11 } };
34
+ const share = shareOfMachine(before, after, 4);
35
+ assert.equal(share?.systemShare, 0.25);
36
+ assert.equal(share?.iowaitShare, 0.25);
37
+ });
38
+
39
+ test("two readings taken at the same instant say nothing rather than dividing by zero", () => {
40
+ assert.equal(shareOfMachine({ takenAt: 5, processCpuSeconds: 1, system: null }, { takenAt: 5, processCpuSeconds: 2, system: null }, 4), null);
41
+ });
42
+
43
+ test("a missing reading leaves that share unknown, not zero", () => {
44
+ const share = shareOfMachine(
45
+ { takenAt: 0, processCpuSeconds: null, system: null },
46
+ { takenAt: 1_000, processCpuSeconds: 1, system: null },
47
+ 4
48
+ );
49
+ assert.equal(share?.processShare, null);
50
+ assert.equal(share?.systemShare, null);
51
+ });
52
+
53
+ test("a process that does not exist reports nothing at all", async () => {
54
+ assert.equal(await readProcessCpuSeconds(0), null);
55
+ assert.equal(await readProcessCpuSeconds(-1), null);
56
+ // A pid far above any real one on this machine.
57
+ assert.equal(await readProcessCpuSeconds(4_000_000), null);
58
+ });
59
+
60
+ test("on a host without /proc the readings are null and the sampler still answers", async () => {
61
+ // This runs on Linux in CI and on Windows here; both must be safe. What is
62
+ // asserted is the SHAPE — a reading is either a number or null, never a throw.
63
+ const system = await readSystemCpu();
64
+ assert.ok(system === null || typeof system.busySeconds === "number");
65
+ const sample = await sampleHost(null);
66
+ assert.equal(typeof sample.takenAt, "number");
67
+ assert.equal(sample.processCpuSeconds, null);
68
+ });