@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 +9 -0
- package/package.json +1 -1
- package/server.js +11 -0
- package/services/hls-session-manager.js +168 -11
- package/services/host-load.js +20 -0
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
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
|
-
|
|
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 = {
|
|
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)}
|
|
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
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
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({
|
package/services/host-load.js
CHANGED
|
@@ -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
|
+
}
|