@torrent-tv/proxy 2.18.0 → 2.20.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,12 @@
1
+ ## 2.20.0
2
+
3
+ - **Fix**: A file's read window is shared between the readers it has, instead of being granted whole to each. The window is stated in seconds of playback and the piece store's memory is one budget for the whole torrent, so a viewer with a picture and a separately published audio track asked for twice what the budget was written against, and a warm-up made it three times. On 2026-08-15 that ended as it had to: every resident piece held at once, a read that returned zero bytes, and every encoder on the file taking that for the end of it. This is the first step of the sliding window, not the whole of it — pieces still leave memory only by the store's own eviction.
4
+
5
+ ## 2.19.0
6
+
7
+ - **New**: What the torrent itself costs this machine is measured and charged. Downloading a file, verifying every piece of it and pushing segments down a data channel are work on the same box as the encoder, they scale with the file's own bitrate, and the budget counted none of it — measured on the addon host with every encoder suspended, the machine was still 20-29 % busy. The figure is taken only while NOTHING is encoding, which is the one moment it can be attributed without arithmetic, and it is expressed per megabyte moved so any file's rate can be priced from it. A viewer's file is then charged at its own byte rate when deciding what quality this host can offer.
8
+ - **New**: The host-load line reports the proxy's own share of the machine beside the encoders'. The two answer different questions — whether ffmpeg is getting the cores, and how much of the box goes to everything around it — and only the first was visible.
9
+
1
10
  ## 2.18.0
2
11
 
3
12
  - **New**: Copying the picture is no longer priced at nothing. It demuxes, re-encodes the audio and writes segments, and it is what runs BESIDE every rung warmed for a quality change — the field measured it at 7.92-8.02x, about an eighth of a second of work per second of video. The figure is not a constant: a session that is copying reports its own speed, and the reciprocal of that IS the cost, learned per file as the decode cost already is (median of recent readings, only from a run past its own start, never from a suspended one).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.18.0",
3
+ "version": "2.20.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
@@ -152,6 +152,17 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
152
152
  stateDir,
153
153
  // Live download stats accessor for the realtime budget: lets it tell a
154
154
  // CPU-bound transcode from a download-starved input before downscaling.
155
+ // What every torrent here has moved, so the proxy can price its own
156
+ // downloading, hashing and delivery against the machine (roadmap item 7).
157
+ getTorrentTotals: async () => {
158
+ let bytesMoved = 0;
159
+ for (const torrent of torrentPool.client?.torrents ?? []) {
160
+ const downloaded = Number(torrent?.downloaded);
161
+ const uploaded = Number(torrent?.uploaded);
162
+ bytesMoved += (Number.isFinite(downloaded) ? downloaded : 0) + (Number.isFinite(uploaded) ? uploaded : 0);
163
+ }
164
+ return { bytesMoved };
165
+ },
155
166
  getSourceStats: async (sourceKey, fileIndex) => {
156
167
  const record = sourceRegistry.get(sourceKey);
157
168
  if (!record) {
@@ -18,7 +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
+ import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
22
22
 
23
23
  /** Own package version, stamped onto session-start log lines. */
24
24
  const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -466,6 +466,8 @@ const DEFAULT_STARTUP_WAIT_MS = 5_000;
466
466
  // and restart at the current segment. Conservative so it never thrashes: a long
467
467
  // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
468
468
  const BUDGET_CHECK_INTERVAL_MS = 5_000;
469
+ /** Below this, a tick has not moved enough of the torrent to price it. */
470
+ const TORRENT_COST_MIN_MEGABYTES = 2;
469
471
  // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
470
472
  // realtime resets the slow window (hysteresis).
471
473
  const BUDGET_SPEED_SLOW = 0.95;
@@ -1161,6 +1163,23 @@ function normalizeLogFileName(fileName, fileIndex) {
1161
1163
  * combination. Sessions are reused across consumers and are automatically
1162
1164
  * expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
1163
1165
  */
1166
+ /**
1167
+ * How many megabytes a second of this source is, from what the probe read.
1168
+ *
1169
+ * A viewer consumes the file at its own rate, so this is also the rate at which
1170
+ * the machine must fetch, verify and deliver it while they watch.
1171
+ *
1172
+ * @param {HlsSession} session
1173
+ * @returns {number | null}
1174
+ */
1175
+ function sourceMegabytesPerSecond(session) {
1176
+ const megabitsPerSecond = Number(session.sourceDecode?.megabitsPerSecond);
1177
+ if (!Number.isFinite(megabitsPerSecond) || megabitsPerSecond <= 0) {
1178
+ return null;
1179
+ }
1180
+ return megabitsPerSecond / 8;
1181
+ }
1182
+
1164
1183
  export class HlsSessionManager {
1165
1184
  /**
1166
1185
  * Recent times from session-create to a servable first segment, in ms.
@@ -1205,6 +1224,12 @@ export class HlsSessionManager {
1205
1224
  #observedCopyCost = new Map();
1206
1225
  /** The previous reading of the machine, to compare the next one against. */
1207
1226
  #hostLoadSample = null;
1227
+ /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
1228
+ #idleLoadSample = null;
1229
+ /** Seconds of this process's CPU per megabyte the torrent moves, once measured. */
1230
+ #observedTorrentCostPerMegabyte = null;
1231
+ /** @type {number[]} Recent readings behind that median. */
1232
+ #torrentCostReadings = [];
1208
1233
 
1209
1234
  /**
1210
1235
  * @param {HlsSessionManagerOptions} options
@@ -1225,8 +1250,8 @@ export class HlsSessionManager {
1225
1250
  getCachedMediaInfo = null,
1226
1251
  getCachedAudioTracks = null,
1227
1252
  segmentFormatId = undefined,
1228
- stateDir = ""
1229
- }) {
1253
+ stateDir = "",
1254
+ getTorrentTotals}) {
1230
1255
  this.enabled = Boolean(enabled);
1231
1256
  this.ffmpegBin = ffmpegBin;
1232
1257
  // Where measurements about this host are kept between runs. Empty means
@@ -1247,6 +1272,10 @@ export class HlsSessionManager {
1247
1272
  // realtime budget to tell a CPU limit from a download-starved input:
1248
1273
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
1249
1274
  this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
1275
+ // Totals across every torrent this proxy holds, used to price what the
1276
+ // torrent itself costs the machine (item 7). Optional: a proxy wired
1277
+ // without it simply never learns that figure.
1278
+ this.getTorrentTotals = typeof getTorrentTotals === "function" ? getTorrentTotals : null;
1250
1279
  // Detected H.264 encoder descriptor (hardware or software). Defaults to
1251
1280
  // software libx264 when no detection result is supplied. May be downgraded
1252
1281
  // to software at runtime if a hardware encode fails.
@@ -2460,10 +2489,42 @@ export class HlsSessionManager {
2460
2489
  return 0;
2461
2490
  }
2462
2491
  const bytesPerSecond = fileLength / durationSeconds;
2463
- const wanted = Math.round(bytesPerSecond * READ_WINDOW_SECONDS);
2492
+ // Shared between the readers this file already has. The window is stated in
2493
+ // seconds of playback and the store's memory is one budget for the whole
2494
+ // torrent, so N readers asking for thirty seconds each ask for N times what
2495
+ // was provided for — and on 2026-08-15 that is exactly what happened: a
2496
+ // viewer with a picture and an audio track had every resident piece held at
2497
+ // once, a read ended with zero bytes, and every encoder on the file took
2498
+ // that for the end of it.
2499
+ //
2500
+ // Dividing keeps the promise the budget was written against. It is not the
2501
+ // sliding window of roadmap item 8 — pieces still leave only by the store's
2502
+ // own eviction — but it removes the multiplication that broke it.
2503
+ const readers = Math.max(1, this.#readersOn(sourceKey, fileIndex));
2504
+ const wanted = Math.round((bytesPerSecond * READ_WINDOW_SECONDS) / readers);
2464
2505
  return Math.min(READ_WINDOW_MAX_BYTES, Math.max(READ_WINDOW_MIN_BYTES, wanted));
2465
2506
  }
2466
2507
 
2508
+ /**
2509
+ * How many live sessions read this file: the picture, any rung being warmed
2510
+ * beside it, and any audio track published on its own.
2511
+ *
2512
+ * @param {string} sourceKey
2513
+ * @param {number} fileIndex
2514
+ * @returns {number}
2515
+ */
2516
+ #readersOn(sourceKey, fileIndex) {
2517
+ let readers = 0;
2518
+ for (const session of this.sessionsById.values()) {
2519
+ if (session?.sourceKey === sourceKey &&
2520
+ session.fileIndex === fileIndex &&
2521
+ session.state !== "disposed") {
2522
+ readers += 1;
2523
+ }
2524
+ }
2525
+ return readers;
2526
+ }
2527
+
2467
2528
  #enforceLookAhead() {
2468
2529
  for (const session of this.sessionsById.values()) {
2469
2530
  this.#enforceLookAheadFor(session);
@@ -2666,14 +2727,85 @@ export class HlsSessionManager {
2666
2727
  * Written only while something is encoding, and only when a reading is
2667
2728
  * available: on a host without `/proc` this says nothing at all.
2668
2729
  */
2730
+ /**
2731
+ * What the torrent itself costs this machine, per megabyte it moves.
2732
+ *
2733
+ * Downloading, verifying every piece and pushing segments down a data channel
2734
+ * are work on the same box as the encoder, they scale with the file's own
2735
+ * bitrate, and the budget counts none of it. Measured on the addon host with
2736
+ * every encoder suspended, the machine was still 20-29 % busy.
2737
+ *
2738
+ * Taken only while NOTHING is encoding, because that is the only moment the
2739
+ * spending can be attributed without arithmetic: what this process uses then
2740
+ * is the torrent's.
2741
+ */
2742
+ async #learnTorrentCost() {
2743
+ const now = {
2744
+ takenAt: Date.now(),
2745
+ cpuSeconds: readProxyCpuSeconds(),
2746
+ bytes: await this.#torrentBytesMoved()
2747
+ };
2748
+ const previous = this.#idleLoadSample;
2749
+ this.#idleLoadSample = now;
2750
+ if (previous === null || now.bytes === null || previous.bytes === null) {
2751
+ return;
2752
+ }
2753
+ const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
2754
+ const megabytes = (now.bytes - previous.bytes) / 1e6;
2755
+ const cpuSeconds = now.cpuSeconds - previous.cpuSeconds;
2756
+ // Enough movement to divide by: a tick with almost nothing downloaded
2757
+ // measures the idle loop, not the torrent.
2758
+ if (!(elapsedSec > 0) || !(megabytes >= TORRENT_COST_MIN_MEGABYTES) || !(cpuSeconds > 0)) {
2759
+ return;
2760
+ }
2761
+ const costPerMegabyte = cpuSeconds / megabytes;
2762
+ const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-DECODE_LEARNING_READINGS);
2763
+ this.#torrentCostReadings = readings;
2764
+ const sorted = [...readings].sort((left, right) => left - right);
2765
+ const median = sorted[Math.floor(sorted.length / 2)];
2766
+ if (this.#observedTorrentCostPerMegabyte !== null &&
2767
+ Math.abs(median - this.#observedTorrentCostPerMegabyte) / this.#observedTorrentCostPerMegabyte < DECODE_LEARNING_CHANGE) {
2768
+ return;
2769
+ }
2770
+ this.#observedTorrentCostPerMegabyte = median;
2771
+ logger.info(
2772
+ `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
2773
+ `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB)`
2774
+ );
2775
+ }
2776
+
2777
+ /**
2778
+ * Bytes this proxy's torrents have moved in total, or null when it cannot be
2779
+ * asked.
2780
+ *
2781
+ * @returns {Promise<number | null>}
2782
+ */
2783
+ async #torrentBytesMoved() {
2784
+ if (typeof this.getTorrentTotals !== "function") {
2785
+ return null;
2786
+ }
2787
+ try {
2788
+ const totals = await this.getTorrentTotals();
2789
+ const moved = Number(totals?.bytesMoved);
2790
+ return Number.isFinite(moved) ? moved : null;
2791
+ } catch {
2792
+ return null; // the pool is busy or gone; a reading missed is not a fault
2793
+ }
2794
+ }
2795
+
2669
2796
  async #reportHostLoad() {
2670
2797
  const encoding = [...this.sessionsById.values()].filter(
2671
2798
  (session) => session?.ffmpeg != null && !hasChildExited(session.ffmpeg) && session.state !== "disposed"
2672
2799
  );
2673
2800
  if (encoding.length === 0) {
2801
+ // Nothing is encoding, which is the ONLY moment the torrent's own cost
2802
+ // can be attributed cleanly: whatever this process spends now is the
2803
+ // download, the hashing, the piece store and the delivery. Item 7.
2804
+ await this.#learnTorrentCost();
2674
2805
  this.#hostLoadSample = null;
2675
2806
  return;
2676
2807
  }
2808
+ this.#idleLoadSample = null;
2677
2809
  // EVERY encoder, added up. One of them is meaningless on a host that runs a
2678
2810
  // picture and an audio track at once, and picking the first would have
2679
2811
  // reported whichever the map happened to hold.
@@ -2697,7 +2829,16 @@ export class HlsSessionManager {
2697
2829
  byPid.set(pid, seconds);
2698
2830
  }
2699
2831
  });
2700
- const sample = { takenAt: Date.now(), byPid, system };
2832
+ const sample = {
2833
+ takenAt: Date.now(),
2834
+ byPid,
2835
+ system,
2836
+ // The proxy's own CPU, across every thread: the torrent, the hashing, the
2837
+ // piece store and the delivery. None of it is in the encode budget, and
2838
+ // on the addon host it is most of what the machine does while encoders
2839
+ // are suspended.
2840
+ proxyCpuSeconds: readProxyCpuSeconds()
2841
+ };
2701
2842
  const previous = this.#hostLoadSample;
2702
2843
  this.#hostLoadSample = sample;
2703
2844
  if (previous === null) {
@@ -2731,8 +2872,13 @@ export class HlsSessionManager {
2731
2872
  const running = encoding.length - suspended;
2732
2873
  const machine = await readMachineState();
2733
2874
  const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
2875
+ const cores = Math.max(1, os.cpus().length);
2876
+ const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
2877
+ ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
2878
+ : null;
2734
2879
  logger.info(
2735
- `host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
2880
+ `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
2881
+ `system=${asPercent(share.systemShare)} ` +
2736
2882
  `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
2737
2883
  `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
2738
2884
  `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
@@ -4883,11 +5029,22 @@ export class HlsSessionManager {
4883
5029
  * @returns {number}
4884
5030
  */
4885
5031
  #committedCostOf(session) {
4886
- if (session.transcodeVideo === true) {
4887
- return 0;
4888
- }
4889
- const observed = this.#observedCopyCost.get(`${session.sourceKey}:${session.fileIndex}`);
4890
- return observed && observed.costSec > 0 ? observed.costSec : 0;
5032
+ let cost = 0;
5033
+ if (session.transcodeVideo !== true) {
5034
+ const observed = this.#observedCopyCost.get(`${session.sourceKey}:${session.fileIndex}`);
5035
+ cost += observed && observed.costSec > 0 ? observed.costSec : 0;
5036
+ }
5037
+ // And what the FILE costs simply by being fetched and delivered while it is
5038
+ // watched: a viewer consumes it at its own byte rate, and every one of
5039
+ // those bytes is downloaded, verified and pushed by this process. Priced
5040
+ // per megabyte from readings taken while nothing was encoding, so the two
5041
+ // measurements do not contain each other.
5042
+ const perMegabyte = this.#observedTorrentCostPerMegabyte;
5043
+ const megabytesPerSecond = sourceMegabytesPerSecond(session);
5044
+ if (perMegabyte !== null && megabytesPerSecond !== null) {
5045
+ cost += perMegabyte * megabytesPerSecond;
5046
+ }
5047
+ return cost;
4891
5048
  }
4892
5049
 
4893
5050
  #sustainableHeights({
@@ -152,3 +152,23 @@ export async function sampleHost(pid) {
152
152
  ]);
153
153
  return { takenAt: Date.now(), processCpuSeconds, system };
154
154
  }
155
+
156
+ /**
157
+ * How much CPU THIS process has used, across every thread it owns.
158
+ *
159
+ * The proxy is not only its encoders. It downloads the torrent, verifies every
160
+ * piece it receives, keeps the piece store, and pushes segments down a data
161
+ * channel — work that happens in this process and its workers, that scales with
162
+ * the file's own bitrate, and that the encode budget counts as nothing.
163
+ * Measured on the addon host with both encoders suspended, the machine was
164
+ * still 20-29 % busy; this is the number that says how much of that is ours.
165
+ *
166
+ * `process.cpuUsage()` covers all threads of the process, so the torrent
167
+ * worker's hashing is included without asking it anything.
168
+ *
169
+ * @returns {number} Seconds of CPU used since this process began.
170
+ */
171
+ export function readProxyCpuSeconds() {
172
+ const usage = process.cpuUsage();
173
+ return (usage.user + usage.system) / 1e6;
174
+ }