@torrent-tv/proxy 2.20.0 → 2.21.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 +7 -0
- package/package.json +1 -1
- package/server.js +3 -6
- package/services/encoder-readings.js +45 -0
- package/services/hls-session-manager.js +96 -15
- package/services/torrent-worker/client.js +510 -497
- package/services/torrent-worker/pool-adapter.js +204 -195
- package/services/torrent-worker/protocol.js +2 -0
- package/services/torrent-worker/worker.js +16 -0
- package/test/encoder-readings.test.js +46 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 2.21.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: The two costs added in 2.18.0 and 2.19.0 were never measured in production — both features were inert. The torrent's cost read `torrentPool.client`, a field that belongs to the pool implementation that no longer runs on this thread (the WebTorrent client lives on the worker), so the byte totals were always zero and the guard that needs two megabytes of movement never passed. The copy's cost sat in a branch its only caller had already filtered out, so it never ran. The totals now come from the worker over its own protocol, and the caller admits a copying session.
|
|
4
|
+
- **Fix**: A speed is read as the DIFFERENCE between two readings of an uninterrupted stretch, not from ffmpeg's cumulative figure. The cumulative one counts every second the look-ahead cap keeps the encoder stopped, and a copy spends most of its life stopped — it reaches the cap in about fifteen seconds and then waits a minute, so a copy running at 8x reports 1.6x and falling. Filed as the price of copying, that would have refused rungs on a measurement of a pause. The pair is dropped whenever the encoder is paused, resumed or restarted, so every surviving pair spans real work.
|
|
5
|
+
- **Fix**: The torrent's cost is divided by the core count. `process.cpuUsage()` adds up every thread, while everything it is added to is wall seconds per second of video — undivided on the four-core addon host it overstated the torrent fourfold, which on the field's own rung is the difference between offering it and refusing it. Only DOWNLOADED bytes are counted, since a byte sent back to the swarm is neither hashed nor stored, and the file is priced by its own length rather than by the video stream's bitrate — the torrent moves the container, and two or three audio tracks are 10-25 % of it.
|
|
6
|
+
- **Fix**: The offered list is recomputed when either new figure changes, and the FIRST offer — the one a viewer sees on opening a file — is priced with the torrent's cost too. Keyed only on the decode version, the cache could never change for a copied picture, which is precisely the case these costs exist for.
|
|
7
|
+
|
|
1
8
|
## 2.20.0
|
|
2
9
|
|
|
3
10
|
- **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.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -155,13 +155,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
155
155
|
// What every torrent here has moved, so the proxy can price its own
|
|
156
156
|
// downloading, hashing and delivery against the machine (roadmap item 7).
|
|
157
157
|
getTorrentTotals: async () => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const downloaded = Number(torrent?.downloaded);
|
|
161
|
-
const uploaded = Number(torrent?.uploaded);
|
|
162
|
-
bytesMoved += (Number.isFinite(downloaded) ? downloaded : 0) + (Number.isFinite(uploaded) ? uploaded : 0);
|
|
158
|
+
if (typeof torrentPool.getTorrentTotals !== "function") {
|
|
159
|
+
return null;
|
|
163
160
|
}
|
|
164
|
-
return
|
|
161
|
+
return torrentPool.getTorrentTotals();
|
|
165
162
|
},
|
|
166
163
|
getSourceStats: async (sourceKey, fileIndex) => {
|
|
167
164
|
const record = sourceRegistry.get(sourceKey);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Turning two readings of a running encoder into a speed.
|
|
3
|
+
*
|
|
4
|
+
* ffmpeg reports `speed=` cumulatively, over the whole run. That figure counts
|
|
5
|
+
* every second the encoder spent SIGSTOPped by the look-ahead cap, and a COPY
|
|
6
|
+
* spends most of its life there — it reaches the cap in about fifteen seconds
|
|
7
|
+
* and then waits a minute. Read that way, a copy running at eight times
|
|
8
|
+
* realtime reports 1.6x and falling; filed as the price of copying, it would
|
|
9
|
+
* refuse quality rungs on arithmetic that had measured a pause.
|
|
10
|
+
*
|
|
11
|
+
* The difference between two readings of an uninterrupted stretch does not have
|
|
12
|
+
* that fault, and it is the same technique the startup benchmarks use.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} EncoderReading
|
|
17
|
+
* @property {number} takenAt - Wall clock, in milliseconds.
|
|
18
|
+
* @property {number} processedSeconds - Output position ffmpeg has reached.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Speed between two readings, or null when the pair cannot answer.
|
|
23
|
+
*
|
|
24
|
+
* @param {EncoderReading | null} previous
|
|
25
|
+
* @param {EncoderReading | null} current
|
|
26
|
+
* @param {number} minimumWindowSec - The narrowest stretch worth dividing by.
|
|
27
|
+
* @returns {number | null} Video seconds produced per second of clock.
|
|
28
|
+
*/
|
|
29
|
+
export function speedFromReadings(previous, current, minimumWindowSec) {
|
|
30
|
+
if (!previous || !current) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const wallSeconds = (current.takenAt - previous.takenAt) / 1000;
|
|
34
|
+
const producedSeconds = current.processedSeconds - previous.processedSeconds;
|
|
35
|
+
if (!Number.isFinite(wallSeconds) || !Number.isFinite(producedSeconds)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// A window too narrow to divide by, or one in which nothing was produced —
|
|
39
|
+
// the second happens when a run has just been repositioned and has not yet
|
|
40
|
+
// reached the position it is restarting from.
|
|
41
|
+
if (!(wallSeconds >= minimumWindowSec) || !(producedSeconds > 0)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return producedSeconds / wallSeconds;
|
|
45
|
+
}
|
|
@@ -19,6 +19,7 @@ import { createRequire } from "node:module";
|
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
|
+
import { speedFromReadings } from "./encoder-readings.js";
|
|
22
23
|
|
|
23
24
|
/** Own package version, stamped onto session-start log lines. */
|
|
24
25
|
const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
@@ -468,6 +469,8 @@ const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
|
468
469
|
const BUDGET_CHECK_INTERVAL_MS = 5_000;
|
|
469
470
|
/** Below this, a tick has not moved enough of the torrent to price it. */
|
|
470
471
|
const TORRENT_COST_MIN_MEGABYTES = 2;
|
|
472
|
+
/** The narrowest stretch of uninterrupted encoding a speed may be read from. */
|
|
473
|
+
const LEARN_WINDOW_MIN_SEC = 3;
|
|
471
474
|
// Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
|
|
472
475
|
// realtime resets the slow window (hysteresis).
|
|
473
476
|
const BUDGET_SPEED_SLOW = 0.95;
|
|
@@ -1170,14 +1173,21 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
1170
1173
|
* the machine must fetch, verify and deliver it while they watch.
|
|
1171
1174
|
*
|
|
1172
1175
|
* @param {HlsSession} session
|
|
1176
|
+
* @param {number | null} fileLengthBytes
|
|
1173
1177
|
* @returns {number | null}
|
|
1174
1178
|
*/
|
|
1175
|
-
function sourceMegabytesPerSecond(session) {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
+
function sourceMegabytesPerSecond(session, fileLengthBytes) {
|
|
1180
|
+
// The FILE's rate, not the video stream's. What the torrent moves is the
|
|
1181
|
+
// container: on the releases this serves, two or three AC-3 tracks add 10-25 %
|
|
1182
|
+
// to what the picture alone would suggest, and `sourceDecode` deliberately
|
|
1183
|
+
// carries the video stream's own bitrate because the decode model was fitted
|
|
1184
|
+
// on video-only clips.
|
|
1185
|
+
const fileLength = Number(fileLengthBytes);
|
|
1186
|
+
const durationSeconds = Number(session.durationSeconds);
|
|
1187
|
+
if (Number.isFinite(fileLength) && fileLength > 0 && Number.isFinite(durationSeconds) && durationSeconds > 0) {
|
|
1188
|
+
return fileLength / durationSeconds / 1e6;
|
|
1179
1189
|
}
|
|
1180
|
-
return
|
|
1190
|
+
return null;
|
|
1181
1191
|
}
|
|
1182
1192
|
|
|
1183
1193
|
export class HlsSessionManager {
|
|
@@ -1226,6 +1236,14 @@ export class HlsSessionManager {
|
|
|
1226
1236
|
#hostLoadSample = null;
|
|
1227
1237
|
/** The previous reading taken while nothing was encoding, for the torrent's own cost. */
|
|
1228
1238
|
#idleLoadSample = null;
|
|
1239
|
+
/**
|
|
1240
|
+
* How long each file is in bytes, from the stats call the read window already
|
|
1241
|
+
* makes. The torrent moves the CONTAINER, so this — not the video stream's
|
|
1242
|
+
* bitrate — is what its work should be priced against.
|
|
1243
|
+
*
|
|
1244
|
+
* @type {Map<string, number>}
|
|
1245
|
+
*/
|
|
1246
|
+
#fileLengthByKey = new Map();
|
|
1229
1247
|
/** Seconds of this process's CPU per megabyte the torrent moves, once measured. */
|
|
1230
1248
|
#observedTorrentCostPerMegabyte = null;
|
|
1231
1249
|
/** @type {number[]} Recent readings behind that median. */
|
|
@@ -2475,6 +2493,9 @@ export class HlsSessionManager {
|
|
|
2475
2493
|
* which leaves the reader on its own default.
|
|
2476
2494
|
*/
|
|
2477
2495
|
async #readWindowBytesFor(sourceKey, fileIndex, durationSeconds) {
|
|
2496
|
+
// The length read here is also what prices the torrent's own work for this
|
|
2497
|
+
// file, so it is remembered rather than discarded.
|
|
2498
|
+
|
|
2478
2499
|
if (!this.getSourceStats || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
2479
2500
|
return 0;
|
|
2480
2501
|
}
|
|
@@ -2488,6 +2509,7 @@ export class HlsSessionManager {
|
|
|
2488
2509
|
if (!Number.isFinite(fileLength) || fileLength <= 0) {
|
|
2489
2510
|
return 0;
|
|
2490
2511
|
}
|
|
2512
|
+
this.#fileLengthByKey.set(`${sourceKey}:${fileIndex}`, fileLength);
|
|
2491
2513
|
const bytesPerSecond = fileLength / durationSeconds;
|
|
2492
2514
|
// Shared between the readers this file already has. The window is stated in
|
|
2493
2515
|
// seconds of playback and the store's memory is one budget for the whole
|
|
@@ -2674,6 +2696,8 @@ export class HlsSessionManager {
|
|
|
2674
2696
|
* @returns {void}
|
|
2675
2697
|
*/
|
|
2676
2698
|
#pauseEncoder(session, reason) {
|
|
2699
|
+
// Any pair spanning this would count a stopped encoder as slow.
|
|
2700
|
+
session.learnSample = null;
|
|
2677
2701
|
if (session.encoderPaused || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
|
|
2678
2702
|
return;
|
|
2679
2703
|
}
|
|
@@ -2702,6 +2726,8 @@ export class HlsSessionManager {
|
|
|
2702
2726
|
* @returns {void}
|
|
2703
2727
|
*/
|
|
2704
2728
|
#resumeEncoder(session, reason) {
|
|
2729
|
+
// Any pair spanning this would count a stopped encoder as slow.
|
|
2730
|
+
session.learnSample = null;
|
|
2705
2731
|
if (!session.encoderPaused || !session.ffmpeg?.pid) {
|
|
2706
2732
|
return;
|
|
2707
2733
|
}
|
|
@@ -2752,7 +2778,13 @@ export class HlsSessionManager {
|
|
|
2752
2778
|
}
|
|
2753
2779
|
const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
|
|
2754
2780
|
const megabytes = (now.bytes - previous.bytes) / 1e6;
|
|
2755
|
-
|
|
2781
|
+
// Divided by the cores, because `process.cpuUsage()` adds up every thread
|
|
2782
|
+
// while everything this figure is later added to is measured in WALL
|
|
2783
|
+
// seconds per second of video. Left undivided on the four-core addon host
|
|
2784
|
+
// it overstated the torrent by four times, which on the field's own rung
|
|
2785
|
+
// was the difference between offering it and refusing it.
|
|
2786
|
+
const cores = Math.max(1, os.cpus().length);
|
|
2787
|
+
const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / cores;
|
|
2756
2788
|
// Enough movement to divide by: a tick with almost nothing downloaded
|
|
2757
2789
|
// measures the idle loop, not the torrent.
|
|
2758
2790
|
if (!(elapsedSec > 0) || !(megabytes >= TORRENT_COST_MIN_MEGABYTES) || !(cpuSeconds > 0)) {
|
|
@@ -2786,8 +2818,12 @@ export class HlsSessionManager {
|
|
|
2786
2818
|
}
|
|
2787
2819
|
try {
|
|
2788
2820
|
const totals = await this.getTorrentTotals();
|
|
2789
|
-
|
|
2790
|
-
|
|
2821
|
+
// Downloaded bytes only. Every one of them is verified against the piece
|
|
2822
|
+
// hash and written to the store; a byte sent back to the swarm is neither,
|
|
2823
|
+
// and adding the two would price both at whatever the mixture happened to
|
|
2824
|
+
// be on the day.
|
|
2825
|
+
const downloaded = Number(totals?.downloaded);
|
|
2826
|
+
return Number.isFinite(downloaded) ? downloaded : null;
|
|
2791
2827
|
} catch {
|
|
2792
2828
|
return null; // the pool is busy or gone; a reading missed is not a fault
|
|
2793
2829
|
}
|
|
@@ -3108,6 +3144,9 @@ export class HlsSessionManager {
|
|
|
3108
3144
|
* @returns {Promise<void>}
|
|
3109
3145
|
*/
|
|
3110
3146
|
async #startEncodeRun(session, startIndex) {
|
|
3147
|
+
// A new run starts its own reckoning: a pair spanning the restart would
|
|
3148
|
+
// count the gap between two runs as slow encoding.
|
|
3149
|
+
session.learnSample = null;
|
|
3111
3150
|
// Where a restart's seconds go. A seek costs 5-8 s in the field and the
|
|
3112
3151
|
// recorded reason — waiting for the previous ffmpeg to exit, measured at
|
|
3113
3152
|
// 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
|
|
@@ -4684,7 +4723,14 @@ export class HlsSessionManager {
|
|
|
4684
4723
|
// Everything else is fixed for the session's life.
|
|
4685
4724
|
const observed = this.#observedDecodeCost.get(`${owner.sourceKey}:${owner.fileIndex}`) ?? null;
|
|
4686
4725
|
const playing = this.variantHeightOf(this.#activeVariant(owner));
|
|
4687
|
-
|
|
4726
|
+
// Everything the answer is derived from belongs in what identifies it. The
|
|
4727
|
+
// copy's price and the torrent's are inputs now, and left out of this key
|
|
4728
|
+
// the menu would keep the answer computed before either was measured — on
|
|
4729
|
+
// a copied picture, which is the case they exist for, the decode version
|
|
4730
|
+
// never moves at all, so the cache would never be recomputed.
|
|
4731
|
+
const copyVersion = this.#observedCopyCost.get(`${owner.sourceKey}:${owner.fileIndex}`)?.version ?? 0;
|
|
4732
|
+
const torrentCost = this.#observedTorrentCostPerMegabyte ?? 0;
|
|
4733
|
+
const version = `${observed?.version ?? 0}:${playing}:${copyVersion}:${torrentCost.toFixed(6)}`;
|
|
4688
4734
|
if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
|
|
4689
4735
|
return owner.offeredHeightsCache;
|
|
4690
4736
|
}
|
|
@@ -4792,19 +4838,42 @@ export class HlsSessionManager {
|
|
|
4792
4838
|
session.state === "failed" ||
|
|
4793
4839
|
!session.ffmpeg ||
|
|
4794
4840
|
session.encoderPaused === true ||
|
|
4795
|
-
session.
|
|
4841
|
+
session.audioOnly === true
|
|
4796
4842
|
) {
|
|
4843
|
+
// An audio rendition is excluded because its speed is the price of a
|
|
4844
|
+
// soundtrack, which neither decoding nor copying the picture is. A COPY
|
|
4845
|
+
// is NOT excluded any more: it says what copying costs, and that used to
|
|
4846
|
+
// be counted as nothing.
|
|
4847
|
+
session.learnSample = null;
|
|
4797
4848
|
return;
|
|
4798
4849
|
}
|
|
4799
|
-
|
|
4800
|
-
|
|
4850
|
+
// Measured as a DELTA between two readings of a run that was going for the
|
|
4851
|
+
// whole interval, not from ffmpeg's cumulative `speed=`. The cumulative
|
|
4852
|
+
// figure counts every second the encoder spent SIGSTOPped by the look-ahead
|
|
4853
|
+
// cap in its denominator, and a copy spends most of its life there — it
|
|
4854
|
+
// reaches the cap in about fifteen seconds and then waits a minute. Read
|
|
4855
|
+
// that way a copy running at 8x reports 1.6x and falling, which would be
|
|
4856
|
+
// filed as the price of copying and refuse rungs on arithmetic that
|
|
4857
|
+
// measured a pause.
|
|
4858
|
+
const processedSeconds = Number(session.progress?.processedSeconds);
|
|
4859
|
+
const takenAt = Date.now();
|
|
4860
|
+
const previous = session.learnSample ?? null;
|
|
4861
|
+
session.learnSample = { takenAt, processedSeconds };
|
|
4862
|
+
if (previous === null || !Number.isFinite(processedSeconds) || !Number.isFinite(previous.processedSeconds)) {
|
|
4863
|
+
return;
|
|
4864
|
+
}
|
|
4865
|
+
const speed = speedFromReadings(previous, { takenAt, processedSeconds }, LEARN_WINDOW_MIN_SEC);
|
|
4866
|
+
if (speed === null) {
|
|
4801
4867
|
return;
|
|
4802
4868
|
}
|
|
4803
4869
|
if (speed < BUDGET_SPEED_OK && await this.#classifyTranscodeBound(session) === "download") {
|
|
4804
4870
|
return; // the torrent is what is short; this says nothing about the host
|
|
4805
4871
|
}
|
|
4806
|
-
session.
|
|
4807
|
-
|
|
4872
|
+
if (session.transcodeVideo === true) {
|
|
4873
|
+
this.#learnDecodeCost(session, speed);
|
|
4874
|
+
return;
|
|
4875
|
+
}
|
|
4876
|
+
await this.#learnCopyCost(session, speed);
|
|
4808
4877
|
}
|
|
4809
4878
|
|
|
4810
4879
|
/**
|
|
@@ -4981,9 +5050,18 @@ export class HlsSessionManager {
|
|
|
4981
5050
|
const observedDecodeCostSec = mediaInfo?.sourceKey !== undefined
|
|
4982
5051
|
? (this.#observedDecodeCost.get(`${mediaInfo.sourceKey}:${mediaInfo.fileIndex}`)?.costSec ?? null)
|
|
4983
5052
|
: null;
|
|
5053
|
+
// What this file costs the machine merely by being fetched and delivered is
|
|
5054
|
+
// known before any session exists, so the FIRST offer — the one the viewer
|
|
5055
|
+
// actually sees when they open a file — is priced with it too. Without this
|
|
5056
|
+
// the plan and a live session answer differently about the same file.
|
|
5057
|
+
const torrentCostSec = this.#observedTorrentCostPerMegabyte !== null && mediaInfo?.fileLength > 0 &&
|
|
5058
|
+
mediaInfo?.durationSeconds > 0
|
|
5059
|
+
? this.#observedTorrentCostPerMegabyte * (mediaInfo.fileLength / mediaInfo.durationSeconds / 1e6)
|
|
5060
|
+
: 0;
|
|
4984
5061
|
const forBranch = (transcodeVideo) =>
|
|
4985
5062
|
this.#sustainableHeights({
|
|
4986
5063
|
heights,
|
|
5064
|
+
concurrentCostSec: torrentCostSec,
|
|
4987
5065
|
observedDecodeCostSec,
|
|
4988
5066
|
// Nothing is running yet, so nothing is exempt from being predicted —
|
|
4989
5067
|
// except the copy itself, which the branch flag already covers.
|
|
@@ -5040,7 +5118,10 @@ export class HlsSessionManager {
|
|
|
5040
5118
|
// per megabyte from readings taken while nothing was encoding, so the two
|
|
5041
5119
|
// measurements do not contain each other.
|
|
5042
5120
|
const perMegabyte = this.#observedTorrentCostPerMegabyte;
|
|
5043
|
-
const megabytesPerSecond = sourceMegabytesPerSecond(
|
|
5121
|
+
const megabytesPerSecond = sourceMegabytesPerSecond(
|
|
5122
|
+
session,
|
|
5123
|
+
this.#fileLengthByKey.get(`${session.sourceKey}:${session.fileIndex}`) ?? null
|
|
5124
|
+
);
|
|
5044
5125
|
if (perMegabyte !== null && megabytesPerSecond !== null) {
|
|
5045
5126
|
cost += perMegabyte * megabytesPerSecond;
|
|
5046
5127
|
}
|