@torrent-tv/proxy 2.18.0 → 2.19.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,8 @@
1
+ ## 2.19.0
2
+
3
+ - **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.
4
+ - **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.
5
+
1
6
  ## 2.18.0
2
7
 
3
8
  - **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.19.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.
@@ -2666,14 +2695,85 @@ export class HlsSessionManager {
2666
2695
  * Written only while something is encoding, and only when a reading is
2667
2696
  * available: on a host without `/proc` this says nothing at all.
2668
2697
  */
2698
+ /**
2699
+ * What the torrent itself costs this machine, per megabyte it moves.
2700
+ *
2701
+ * Downloading, verifying every piece and pushing segments down a data channel
2702
+ * are work on the same box as the encoder, they scale with the file's own
2703
+ * bitrate, and the budget counts none of it. Measured on the addon host with
2704
+ * every encoder suspended, the machine was still 20-29 % busy.
2705
+ *
2706
+ * Taken only while NOTHING is encoding, because that is the only moment the
2707
+ * spending can be attributed without arithmetic: what this process uses then
2708
+ * is the torrent's.
2709
+ */
2710
+ async #learnTorrentCost() {
2711
+ const now = {
2712
+ takenAt: Date.now(),
2713
+ cpuSeconds: readProxyCpuSeconds(),
2714
+ bytes: await this.#torrentBytesMoved()
2715
+ };
2716
+ const previous = this.#idleLoadSample;
2717
+ this.#idleLoadSample = now;
2718
+ if (previous === null || now.bytes === null || previous.bytes === null) {
2719
+ return;
2720
+ }
2721
+ const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
2722
+ const megabytes = (now.bytes - previous.bytes) / 1e6;
2723
+ const cpuSeconds = now.cpuSeconds - previous.cpuSeconds;
2724
+ // Enough movement to divide by: a tick with almost nothing downloaded
2725
+ // measures the idle loop, not the torrent.
2726
+ if (!(elapsedSec > 0) || !(megabytes >= TORRENT_COST_MIN_MEGABYTES) || !(cpuSeconds > 0)) {
2727
+ return;
2728
+ }
2729
+ const costPerMegabyte = cpuSeconds / megabytes;
2730
+ const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-DECODE_LEARNING_READINGS);
2731
+ this.#torrentCostReadings = readings;
2732
+ const sorted = [...readings].sort((left, right) => left - right);
2733
+ const median = sorted[Math.floor(sorted.length / 2)];
2734
+ if (this.#observedTorrentCostPerMegabyte !== null &&
2735
+ Math.abs(median - this.#observedTorrentCostPerMegabyte) / this.#observedTorrentCostPerMegabyte < DECODE_LEARNING_CHANGE) {
2736
+ return;
2737
+ }
2738
+ this.#observedTorrentCostPerMegabyte = median;
2739
+ logger.info(
2740
+ `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
2741
+ `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB)`
2742
+ );
2743
+ }
2744
+
2745
+ /**
2746
+ * Bytes this proxy's torrents have moved in total, or null when it cannot be
2747
+ * asked.
2748
+ *
2749
+ * @returns {Promise<number | null>}
2750
+ */
2751
+ async #torrentBytesMoved() {
2752
+ if (typeof this.getTorrentTotals !== "function") {
2753
+ return null;
2754
+ }
2755
+ try {
2756
+ const totals = await this.getTorrentTotals();
2757
+ const moved = Number(totals?.bytesMoved);
2758
+ return Number.isFinite(moved) ? moved : null;
2759
+ } catch {
2760
+ return null; // the pool is busy or gone; a reading missed is not a fault
2761
+ }
2762
+ }
2763
+
2669
2764
  async #reportHostLoad() {
2670
2765
  const encoding = [...this.sessionsById.values()].filter(
2671
2766
  (session) => session?.ffmpeg != null && !hasChildExited(session.ffmpeg) && session.state !== "disposed"
2672
2767
  );
2673
2768
  if (encoding.length === 0) {
2769
+ // Nothing is encoding, which is the ONLY moment the torrent's own cost
2770
+ // can be attributed cleanly: whatever this process spends now is the
2771
+ // download, the hashing, the piece store and the delivery. Item 7.
2772
+ await this.#learnTorrentCost();
2674
2773
  this.#hostLoadSample = null;
2675
2774
  return;
2676
2775
  }
2776
+ this.#idleLoadSample = null;
2677
2777
  // EVERY encoder, added up. One of them is meaningless on a host that runs a
2678
2778
  // picture and an audio track at once, and picking the first would have
2679
2779
  // reported whichever the map happened to hold.
@@ -2697,7 +2797,16 @@ export class HlsSessionManager {
2697
2797
  byPid.set(pid, seconds);
2698
2798
  }
2699
2799
  });
2700
- const sample = { takenAt: Date.now(), byPid, system };
2800
+ const sample = {
2801
+ takenAt: Date.now(),
2802
+ byPid,
2803
+ system,
2804
+ // The proxy's own CPU, across every thread: the torrent, the hashing, the
2805
+ // piece store and the delivery. None of it is in the encode budget, and
2806
+ // on the addon host it is most of what the machine does while encoders
2807
+ // are suspended.
2808
+ proxyCpuSeconds: readProxyCpuSeconds()
2809
+ };
2701
2810
  const previous = this.#hostLoadSample;
2702
2811
  this.#hostLoadSample = sample;
2703
2812
  if (previous === null) {
@@ -2731,8 +2840,13 @@ export class HlsSessionManager {
2731
2840
  const running = encoding.length - suspended;
2732
2841
  const machine = await readMachineState();
2733
2842
  const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
2843
+ const cores = Math.max(1, os.cpus().length);
2844
+ const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
2845
+ ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
2846
+ : null;
2734
2847
  logger.info(
2735
- `host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
2848
+ `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
2849
+ `system=${asPercent(share.systemShare)} ` +
2736
2850
  `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
2737
2851
  `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
2738
2852
  `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
@@ -4883,11 +4997,22 @@ export class HlsSessionManager {
4883
4997
  * @returns {number}
4884
4998
  */
4885
4999
  #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;
5000
+ let cost = 0;
5001
+ if (session.transcodeVideo !== true) {
5002
+ const observed = this.#observedCopyCost.get(`${session.sourceKey}:${session.fileIndex}`);
5003
+ cost += observed && observed.costSec > 0 ? observed.costSec : 0;
5004
+ }
5005
+ // And what the FILE costs simply by being fetched and delivered while it is
5006
+ // watched: a viewer consumes it at its own byte rate, and every one of
5007
+ // those bytes is downloaded, verified and pushed by this process. Priced
5008
+ // per megabyte from readings taken while nothing was encoding, so the two
5009
+ // measurements do not contain each other.
5010
+ const perMegabyte = this.#observedTorrentCostPerMegabyte;
5011
+ const megabytesPerSecond = sourceMegabytesPerSecond(session);
5012
+ if (perMegabyte !== null && megabytesPerSecond !== null) {
5013
+ cost += perMegabyte * megabytesPerSecond;
5014
+ }
5015
+ return cost;
4891
5016
  }
4892
5017
 
4893
5018
  #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
+ }