@torrent-tv/proxy 2.19.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 CHANGED
@@ -1,3 +1,14 @@
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
+
8
+ ## 2.20.0
9
+
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.
11
+
1
12
  ## 2.19.0
2
13
 
3
14
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.19.0",
3
+ "version": "2.21.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
@@ -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
- 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);
158
+ if (typeof torrentPool.getTorrentTotals !== "function") {
159
+ return null;
163
160
  }
164
- return { bytesMoved };
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
- const megabitsPerSecond = Number(session.sourceDecode?.megabitsPerSecond);
1177
- if (!Number.isFinite(megabitsPerSecond) || megabitsPerSecond <= 0) {
1178
- return null;
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 megabitsPerSecond / 8;
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,11 +2509,44 @@ 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
- const wanted = Math.round(bytesPerSecond * READ_WINDOW_SECONDS);
2514
+ // Shared between the readers this file already has. The window is stated in
2515
+ // seconds of playback and the store's memory is one budget for the whole
2516
+ // torrent, so N readers asking for thirty seconds each ask for N times what
2517
+ // was provided for — and on 2026-08-15 that is exactly what happened: a
2518
+ // viewer with a picture and an audio track had every resident piece held at
2519
+ // once, a read ended with zero bytes, and every encoder on the file took
2520
+ // that for the end of it.
2521
+ //
2522
+ // Dividing keeps the promise the budget was written against. It is not the
2523
+ // sliding window of roadmap item 8 — pieces still leave only by the store's
2524
+ // own eviction — but it removes the multiplication that broke it.
2525
+ const readers = Math.max(1, this.#readersOn(sourceKey, fileIndex));
2526
+ const wanted = Math.round((bytesPerSecond * READ_WINDOW_SECONDS) / readers);
2493
2527
  return Math.min(READ_WINDOW_MAX_BYTES, Math.max(READ_WINDOW_MIN_BYTES, wanted));
2494
2528
  }
2495
2529
 
2530
+ /**
2531
+ * How many live sessions read this file: the picture, any rung being warmed
2532
+ * beside it, and any audio track published on its own.
2533
+ *
2534
+ * @param {string} sourceKey
2535
+ * @param {number} fileIndex
2536
+ * @returns {number}
2537
+ */
2538
+ #readersOn(sourceKey, fileIndex) {
2539
+ let readers = 0;
2540
+ for (const session of this.sessionsById.values()) {
2541
+ if (session?.sourceKey === sourceKey &&
2542
+ session.fileIndex === fileIndex &&
2543
+ session.state !== "disposed") {
2544
+ readers += 1;
2545
+ }
2546
+ }
2547
+ return readers;
2548
+ }
2549
+
2496
2550
  #enforceLookAhead() {
2497
2551
  for (const session of this.sessionsById.values()) {
2498
2552
  this.#enforceLookAheadFor(session);
@@ -2642,6 +2696,8 @@ export class HlsSessionManager {
2642
2696
  * @returns {void}
2643
2697
  */
2644
2698
  #pauseEncoder(session, reason) {
2699
+ // Any pair spanning this would count a stopped encoder as slow.
2700
+ session.learnSample = null;
2645
2701
  if (session.encoderPaused || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
2646
2702
  return;
2647
2703
  }
@@ -2670,6 +2726,8 @@ export class HlsSessionManager {
2670
2726
  * @returns {void}
2671
2727
  */
2672
2728
  #resumeEncoder(session, reason) {
2729
+ // Any pair spanning this would count a stopped encoder as slow.
2730
+ session.learnSample = null;
2673
2731
  if (!session.encoderPaused || !session.ffmpeg?.pid) {
2674
2732
  return;
2675
2733
  }
@@ -2720,7 +2778,13 @@ export class HlsSessionManager {
2720
2778
  }
2721
2779
  const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
2722
2780
  const megabytes = (now.bytes - previous.bytes) / 1e6;
2723
- const cpuSeconds = now.cpuSeconds - previous.cpuSeconds;
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;
2724
2788
  // Enough movement to divide by: a tick with almost nothing downloaded
2725
2789
  // measures the idle loop, not the torrent.
2726
2790
  if (!(elapsedSec > 0) || !(megabytes >= TORRENT_COST_MIN_MEGABYTES) || !(cpuSeconds > 0)) {
@@ -2754,8 +2818,12 @@ export class HlsSessionManager {
2754
2818
  }
2755
2819
  try {
2756
2820
  const totals = await this.getTorrentTotals();
2757
- const moved = Number(totals?.bytesMoved);
2758
- return Number.isFinite(moved) ? moved : null;
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;
2759
2827
  } catch {
2760
2828
  return null; // the pool is busy or gone; a reading missed is not a fault
2761
2829
  }
@@ -3076,6 +3144,9 @@ export class HlsSessionManager {
3076
3144
  * @returns {Promise<void>}
3077
3145
  */
3078
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;
3079
3150
  // Where a restart's seconds go. A seek costs 5-8 s in the field and the
3080
3151
  // recorded reason — waiting for the previous ffmpeg to exit, measured at
3081
3152
  // 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
@@ -4652,7 +4723,14 @@ export class HlsSessionManager {
4652
4723
  // Everything else is fixed for the session's life.
4653
4724
  const observed = this.#observedDecodeCost.get(`${owner.sourceKey}:${owner.fileIndex}`) ?? null;
4654
4725
  const playing = this.variantHeightOf(this.#activeVariant(owner));
4655
- const version = `${observed?.version ?? 0}:${playing}`;
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)}`;
4656
4734
  if (Array.isArray(owner.offeredHeightsCache) && owner.offeredHeightsVersion === version) {
4657
4735
  return owner.offeredHeightsCache;
4658
4736
  }
@@ -4760,19 +4838,42 @@ export class HlsSessionManager {
4760
4838
  session.state === "failed" ||
4761
4839
  !session.ffmpeg ||
4762
4840
  session.encoderPaused === true ||
4763
- session.transcodeVideo !== true
4841
+ session.audioOnly === true
4764
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;
4848
+ return;
4849
+ }
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)) {
4765
4863
  return;
4766
4864
  }
4767
- const speed = this.#parseSpeed(session.progress?.speed);
4768
- if (speed === null || speed === session.lastLearnedSpeed) {
4865
+ const speed = speedFromReadings(previous, { takenAt, processedSeconds }, LEARN_WINDOW_MIN_SEC);
4866
+ if (speed === null) {
4769
4867
  return;
4770
4868
  }
4771
4869
  if (speed < BUDGET_SPEED_OK && await this.#classifyTranscodeBound(session) === "download") {
4772
4870
  return; // the torrent is what is short; this says nothing about the host
4773
4871
  }
4774
- session.lastLearnedSpeed = speed;
4775
- this.#learnDecodeCost(session, speed);
4872
+ if (session.transcodeVideo === true) {
4873
+ this.#learnDecodeCost(session, speed);
4874
+ return;
4875
+ }
4876
+ await this.#learnCopyCost(session, speed);
4776
4877
  }
4777
4878
 
4778
4879
  /**
@@ -4949,9 +5050,18 @@ export class HlsSessionManager {
4949
5050
  const observedDecodeCostSec = mediaInfo?.sourceKey !== undefined
4950
5051
  ? (this.#observedDecodeCost.get(`${mediaInfo.sourceKey}:${mediaInfo.fileIndex}`)?.costSec ?? null)
4951
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;
4952
5061
  const forBranch = (transcodeVideo) =>
4953
5062
  this.#sustainableHeights({
4954
5063
  heights,
5064
+ concurrentCostSec: torrentCostSec,
4955
5065
  observedDecodeCostSec,
4956
5066
  // Nothing is running yet, so nothing is exempt from being predicted —
4957
5067
  // except the copy itself, which the branch flag already covers.
@@ -5008,7 +5118,10 @@ export class HlsSessionManager {
5008
5118
  // per megabyte from readings taken while nothing was encoding, so the two
5009
5119
  // measurements do not contain each other.
5010
5120
  const perMegabyte = this.#observedTorrentCostPerMegabyte;
5011
- const megabytesPerSecond = sourceMegabytesPerSecond(session);
5121
+ const megabytesPerSecond = sourceMegabytesPerSecond(
5122
+ session,
5123
+ this.#fileLengthByKey.get(`${session.sourceKey}:${session.fileIndex}`) ?? null
5124
+ );
5012
5125
  if (perMegabyte !== null && megabytesPerSecond !== null) {
5013
5126
  cost += perMegabyte * megabytesPerSecond;
5014
5127
  }