@torrent-tv/proxy 2.97.6 → 2.97.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.6",
3
+ "version": "2.97.7",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -802,6 +802,7 @@ export async function startProxyServer({
802
802
  // thread, so the map crosses the worker channel; what it does with it —
803
803
  // seconds into bytes, what to ask the swarm for, what to keep in memory —
804
804
  // is its own business.
805
+ setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
805
806
  setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
806
807
  void sourcePreparation?.refresh();
807
808
  const record = sourceRegistry.get(sourceKey);
@@ -89,6 +89,11 @@ export class HostLoad {
89
89
  /**
90
90
  * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
91
91
  */
92
+ /** The share of one core this process draws doing nothing priced; zero until measured. */
93
+ get baseDraw() {
94
+ return this.#observedBaseDraw ?? 0;
95
+ }
96
+
92
97
  constructor(host) {
93
98
  this.#host = host;
94
99
  }
@@ -97,6 +97,9 @@ import path from "node:path";
97
97
  import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
98
98
  import { configurationKeyOf } from "../encode/fingerprint.js";
99
99
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
100
+ import os from "node:os";
101
+ import { downloadShare } from "../storage/processor-share.js";
102
+ import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
100
103
 
101
104
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
102
105
  // and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
@@ -193,6 +196,7 @@ export function wireOutputs({
193
196
  decodeCostModel = null,
194
197
  getSourceStats = null,
195
198
  setPriorityMap = null,
199
+ setDownloadRate = null,
196
200
  // What a second job costs on this host, measured at startup. Null when it
197
201
  // could not be measured, and then nothing is corrected — the alternative
198
202
  // is inventing a penalty, which is the same fault as inventing a fill rate.
@@ -342,7 +346,7 @@ export function wireOutputs({
342
346
  get encoders() { return parts.encoders; },
343
347
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
344
348
  productionFailed: (output) => parts.renditions.noteProductionFailed(output),
345
- encodersChanged: () => onEncodersChanged?.(),
349
+ encodersChanged: () => { parts.applyDownloadShare?.(); onEncodersChanged?.(); },
346
350
  });
347
351
  // How much film is ready in front of the viewers, said; and the spare soundtracks fetched once it is full.
348
352
  parts.cushion = new CushionReport({
@@ -648,6 +652,31 @@ export function wireOutputs({
648
652
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
649
653
  parts.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
650
654
  parts.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
655
+ parts.setDownloadRate = typeof setDownloadRate === "function" ? setDownloadRate : null;
656
+ // HOW FAST THE TORRENTS MAY DOWNLOAD: the processor the encoders leave
657
+ // (`storage/processor-share.js`, torrent-tv/meta#178). Worked out again
658
+ // when the machine is read and when an encoder starts or ends, over every
659
+ // watched file of every torrent.
660
+ parts.applyDownloadShare = () => {
661
+ if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
662
+ const files = new Map();
663
+ for (const output of parts.outputs.values()) {
664
+ if (!(parts.viewers?.forOutput(output)?.size > 0)) continue;
665
+ const key = output.file.key;
666
+ const length = Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(key));
667
+ const file = files.get(key) ?? { bytesPerFilmSecond: length / Number(output.file.durationSeconds), encodeCostSec: 0 };
668
+ const state = parts.encodeRuns.runStateOf(output);
669
+ if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
670
+ file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
671
+ }
672
+ files.set(key, file);
673
+ }
674
+ const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
675
+ const share = downloadShare({ cores: os.cpus().length, availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
676
+ baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
677
+ void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
678
+ `over ${watched.length} file(s), ${share.capacity.toFixed(2)} core(s) for the proxy`)).catch(() => {});
679
+ };
651
680
  parts.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
652
681
  // Seconds of film per second when the picture is COPIED, measured at
653
682
  // startup. Nothing else prices that branch: the other benchmarks measure
@@ -1060,7 +1089,7 @@ export function wireOutputs({
1060
1089
  liveConsumers: (...args) => parts.renditions.liveConsumers(...args),
1061
1090
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
1062
1091
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
1063
- reportHostLoad: (...args) => parts.hostLoad.reportHostLoad(...args),
1092
+ reportHostLoad: async (...args) => { await parts.hostLoad.reportHostLoad(...args); parts.applyDownloadShare?.(); },
1064
1093
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
1065
1094
  sampleDownloadRates: (...args) => parts.hostLoad.sampleDownloadRates(...args),
1066
1095
  get encodeCost() { return parts.encodeCost; },
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file How fast the torrents may download, so that downloading does not take
3
+ * the processor the encoders need (torrent-tv/meta#178).
4
+ *
5
+ * Field 2026-10-11, Home Assistant: an AVI re-encoded at 720x400 needs 2.3 s
6
+ * of processor per second of film. While the torrent downloaded 4.5-9.8 MB/s —
7
+ * mostly far ahead of the viewer — its thread took 56-63 % of a core, ffmpeg
8
+ * got 176-182 %, below real time, and the viewer saw the spinner; once the
9
+ * download stopped ffmpeg got 216-261 % and there were no stops.
10
+ *
11
+ * THE MODEL. The processor the proxy has, `C`, is what other processes leave
12
+ * of every core, less what the proxy draws doing nothing priced. Every watched
13
+ * file `f` of every torrent advances at a film speed `s`: its encoders take
14
+ * `s·k_f` (seconds of processor per second of film, as measured for the
15
+ * encoders running now) and its download `s·w_f·c` (`w_f` its bytes per second
16
+ * of film, `c` the measured processor per byte moved). One speed for all is
17
+ * the division under which the slowest file is as fast as it can be:
18
+ *
19
+ * s* = C / Σ_f (k_f + w_f·c), download rate = max(s*, 1) · Σ_f w_f
20
+ *
21
+ * Downloading faster than `s*·w` only piles bytes ahead while the encoders
22
+ * starve; what is fetched first within the rate is the priority map's order.
23
+ * The rate never falls below the films' own byte rate, `Σ w`, because below it
24
+ * the viewer stops whatever the encoders get. Where `s* < 1` or the swarm
25
+ * gives less than the rate, stops cannot be avoided, and the start forecast,
26
+ * reading the speeds measured under this rate, waits for them at the start
27
+ * and after a seek. With nothing encoding `k` is zero and the download has the
28
+ * processor. A term nobody has measured contributes nothing: an unmeasured
29
+ * price of a byte leaves the download unlimited.
30
+ */
31
+
32
+ /**
33
+ * @param {object} params
34
+ * @param {number} params.cores
35
+ * @param {number} params.availableShare - The share of the machine other processes leave, 0..1.
36
+ * @param {number} params.baseDraw - Processor the proxy draws doing nothing priced, in cores.
37
+ * @param {number | null} params.costPerMegabyte - Processor seconds per megabyte the torrent moves.
38
+ * @param {{ bytesPerFilmSecond: number, encodeCostSec: number }[]} params.files - Watched files.
39
+ * @returns {{ filmSpeed: number, bytesPerSecond: number, capacity: number }}
40
+ * `bytesPerSecond` is Infinity where the download is not limited.
41
+ */
42
+ export function downloadShare({ cores, availableShare, baseDraw, costPerMegabyte, files }) {
43
+ const capacity = Math.max(0, cores * availableShare - baseDraw);
44
+ // A byte whose price is not measured, or costs nothing, takes nothing from
45
+ // the encoders: there is then no share to keep the download to.
46
+ const perByte = Number.isFinite(costPerMegabyte) && costPerMegabyte > 0 ? costPerMegabyte / 1e6 : 0;
47
+ const bytes = files.reduce((sum, file) => sum + file.bytesPerFilmSecond, 0);
48
+ const demand = files.reduce((sum, file) => sum + file.encodeCostSec + file.bytesPerFilmSecond * perByte, 0);
49
+ const filmSpeed = capacity / demand;
50
+ const bytesPerSecond = perByte > 0 ? Math.max(filmSpeed, 1) * bytes : Infinity;
51
+ return { filmSpeed, capacity, bytesPerSecond: Number.isFinite(bytesPerSecond) && bytes > 0 ? bytesPerSecond : Infinity };
52
+ }
@@ -1261,6 +1261,35 @@ export class TorrentPool {
1261
1261
  * film against a number, highest first in urgency.
1262
1262
  * @param {number} durationSeconds
1263
1263
  */
1264
+ /**
1265
+ * How fast every torrent may download in all: the share of the processor
1266
+ * the encoders leave (`storage/processor-share.js`, torrent-tv/meta#178).
1267
+ * Within it the swarm is asked for what the priority map puts first.
1268
+ *
1269
+ * @param {number | null} bytesPerSecond - Infinity or null for no limit.
1270
+ * @param {string} [because]
1271
+ */
1272
+ setDownloadRate(bytesPerSecond, because = "") {
1273
+ if (!this.client || this.client.destroyed || typeof this.client.throttleDownload !== "function") return;
1274
+ const rate = Number.isFinite(bytesPerSecond) ? Math.max(1, Math.round(bytesPerSecond)) : -1;
1275
+ if (rate === this.#downloadLimit) return;
1276
+ this.client.throttleDownload(rate);
1277
+ // Said when the limit is set or lifted, and when it moves by a factor of
1278
+ // two either way — the scale on which a limit changes what the swarm gives.
1279
+ const scale = rate < 0 ? null : Math.round(Math.log2(rate));
1280
+ if (scale !== this.#downloadLimitScale) {
1281
+ this.#downloadLimitScale = scale;
1282
+ logger.info(`torrent-pool: download limit -> ${rate < 0 ? "none" : `${Math.round(rate / 1024)} KB/s`}${because ? ` (${because})` : ""}`);
1283
+ }
1284
+ this.#downloadLimit = rate;
1285
+ }
1286
+
1287
+ /** The download limit in force, -1 for none. */
1288
+ #downloadLimit = -1;
1289
+
1290
+ /** The power of two the last limit said was nearest, null for none. */
1291
+ #downloadLimitScale = null;
1292
+
1264
1293
  applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
1265
1294
  // A MAP WITH NOTHING IN IT IS A STATEMENT, and it is answered before
1266
1295
  // anything else is looked at. It says nobody wants any of this file, and
@@ -475,6 +475,17 @@ export class TorrentWorkerClient {
475
475
  * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
476
476
  * @returns {Promise<void>}
477
477
  */
478
+ /**
479
+ * How fast the torrents may download in all (`Command.DOWNLOAD_RATE`).
480
+ *
481
+ * @param {number} bytesPerSecond - Infinity for no limit.
482
+ * @param {string} [because]
483
+ * @returns {Promise<void>}
484
+ */
485
+ async setDownloadRate(bytesPerSecond, because = "") {
486
+ return this.#caller.call(Command.DOWNLOAD_RATE, { bytesPerSecond: Number.isFinite(bytesPerSecond) ? bytesPerSecond : null, because });
487
+ }
488
+
478
489
  async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
479
490
  return this.#caller.call(Command.PRIORITY_MAP, {
480
491
  sourceKey,
@@ -164,6 +164,17 @@ export class WorkerTorrentPool {
164
164
  * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
165
165
  * @returns {Promise<void>}
166
166
  */
167
+ /**
168
+ * How fast the torrents may download in all, Infinity for no limit.
169
+ *
170
+ * @param {number} bytesPerSecond
171
+ * @param {string} [because]
172
+ * @returns {Promise<void>}
173
+ */
174
+ async setDownloadRate(bytesPerSecond, because = "") {
175
+ return this.#client.setDownloadRate(bytesPerSecond, because);
176
+ }
177
+
167
178
  async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
168
179
  if (!sourceKey) {
169
180
  return;
@@ -66,6 +66,12 @@ export const Command = {
66
66
  * sixteen pieces is what tore a film apart on 2026-09-05.
67
67
  */
68
68
  PRIORITY_MAP: "priority-map",
69
+ /**
70
+ * How fast the torrents may download in all, in bytes per second, or
71
+ * Infinity for no limit: the share of the processor the encoders leave
72
+ * (`storage/processor-share.js`, torrent-tv/meta#178).
73
+ */
74
+ DOWNLOAD_RATE: "download-rate",
69
75
  /** Read a byte range; the body arrives as CHUNK messages. */
70
76
  READ_RANGE: "read-range",
71
77
  /** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
@@ -476,6 +476,11 @@ async function runCommand(command, params, id) {
476
476
  return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
477
477
  }
478
478
 
479
+ case Command.DOWNLOAD_RATE: {
480
+ pool.setDownloadRate(params.bytesPerSecond, params.because);
481
+ return true;
482
+ }
483
+
479
484
  case Command.READ_RANGE: {
480
485
  // Streams its own reply; the caller's promise resolves once the body has
481
486
  // been fully sent, which is what lets the client await completion.