@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 +1 -1
- package/server.js +1 -0
- package/services/encode/quality/HostLoad.js +5 -0
- package/services/server/wire-outputs.js +31 -2
- package/services/storage/processor-share.js +52 -0
- package/services/torrent/torrent-pool.js +29 -0
- package/services/torrent/worker/client.js +11 -0
- package/services/torrent/worker/pool-adapter.js +11 -0
- package/services/torrent/worker/protocol.js +6 -0
- package/services/torrent/worker/worker.js +5 -0
package/package.json
CHANGED
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.
|