@torrent-tv/proxy 2.97.6 → 2.97.8

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.8",
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);
@@ -50,11 +50,12 @@ export class EncodeInputs {
50
50
  #failed;
51
51
  #capacity;
52
52
  #urgent;
53
+ #rankAt;
53
54
  #log;
54
55
  #now;
55
56
 
56
57
  constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
57
- openOf = () => null, log = () => {}, now = Date.now }) {
58
+ rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now }) {
58
59
  this.#openOf = openOf;
59
60
  this.#resolve = resolve;
60
61
  this.#read = readRanges;
@@ -64,6 +65,7 @@ export class EncodeInputs {
64
65
  this.#failed = failed;
65
66
  this.#capacity = capacity;
66
67
  this.#urgent = urgent;
68
+ this.#rankAt = rankAt;
67
69
  this.#log = log;
68
70
  this.#now = now;
69
71
  }
@@ -71,12 +73,20 @@ export class EncodeInputs {
71
73
  /**
72
74
  * One line when a request's state changes, so a wait that never ends names
73
75
  * what it waits for. `detail` is said with it and does not count as a change.
76
+ *
77
+ * It names the piece the input is prepared FOR and that piece's rank in the
78
+ * output's map: an input read from the keyframe before starts one piece
79
+ * early, and its own first piece would understate how urgently it is wanted
80
+ * (torrent-tv/meta#178).
74
81
  */
75
82
  #note(request, state, detail = "") {
76
83
  if (request.noted === state) return;
77
84
  request.noted = state;
85
+ const serves = request.serves ?? request.from;
86
+ const rank = this.#rankAt(request.output, serves);
78
87
  this.#log(`encode input #${request.from}..#${request.to} of ${request.output.outputKey ?? request.output.id}: ${state}` +
79
- ` (held ${this.#held} of ${this.#allowed} allowed)${detail}`);
88
+ ` (held ${this.#held} of ${this.#allowed} allowed; for #${serves}` +
89
+ `${rank ? `, rank ${rank.rank} of ${rank.topRank}` : ""})${detail}`);
80
90
  }
81
91
 
82
92
  /** The container's statement of an interval, its time added to the request's account. */
@@ -183,11 +193,14 @@ export class EncodeInputs {
183
193
  * `to` is the last number the run may reach. A packet input is prepared for
184
194
  * `from` alone; an original input for the longest stretch from `from` that
185
195
  * is present, fits memory and copies no longer than an open of that input.
186
- * The answer carries the stretch it holds as `from` and `to`.
196
+ * The answer carries the stretch it holds as `from` and `to`. `serves` is
197
+ * the piece the run is started for, which is `from` unless the input is read
198
+ * from the keyframe before.
187
199
  */
188
- take(output, from, to) {
200
+ take(output, from, to, serves = from) {
189
201
  const key = keyOf(output, from);
190
202
  let existing = this.#requests.get(key);
203
+ if (existing) existing.serves = serves;
191
204
  if (existing && existing.to !== to) {
192
205
  // The plan's bound for this start moved. A copy already made beyond it
193
206
  // holds the shorter stretch whole, so the run is commanded over its
@@ -203,7 +216,7 @@ export class EncodeInputs {
203
216
  return existing.result;
204
217
  }
205
218
  if (existing?.pending || existing?.revision === this.#revision || existing?.result?.kind === "terminal") return null;
206
- const request = existing ?? { output, from, to, key };
219
+ const request = existing ?? { output, from, to, key, serves };
207
220
  this.#requests.set(key, request);
208
221
  this.#prepare(request);
209
222
  return null;
@@ -899,7 +899,7 @@ export class EncodeRuns {
899
899
  const mayReach = this.#inputFailures.boundFor(session.outputKey, startIndex,
900
900
  Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
901
901
  : segmentCount > startIndex ? segmentCount - 1 : startIndex);
902
- const admittedInput = this.#host.encodeInputs?.take(session, inputStart, mayReach) ?? null;
902
+ const admittedInput = this.#host.encodeInputs?.take(session, inputStart, mayReach, startIndex) ?? null;
903
903
  if (!admittedInput) return null;
904
904
  const inputEnd = admittedInput.original ? admittedInput.to : startIndex;
905
905
  // The stretch is part of an original input's identity: two stretches from
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * What the proxy's own work costs this host, read while it runs.
3
3
  *
4
- * The torrent's CPU per megabyte it moves, the share of a core this process
5
- * draws doing nothing priced, how fast each watched torrent is moving, the speed
4
+ * The torrent thread's processor per megabyte it downloads, the share of the
5
+ * machine this process draws doing nothing priced, how fast each watched torrent is moving, the speed
6
6
  * each file's supply demands, how long each file is, and how much of the machine
7
7
  * a new encoder could have. The encoders' own cost is `EncodeCost`'s.
8
8
  */
@@ -30,9 +30,32 @@ export class HostLoad {
30
30
  /** The previous reading of the machine, to compare the next one against. */
31
31
  #hostLoadSample = null;
32
32
 
33
- /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
33
+ /** The previous reading taken while nothing was encoding, for this process's base draw. */
34
34
  #idleLoadSample = null;
35
35
 
36
+ /** The previous reading of the torrent thread's processor and the bytes downloaded. */
37
+ #torrentSample = null;
38
+
39
+ /** A reading of the torrent thread is under way; it asks the worker, which may not answer. */
40
+ #readingTorrent = false;
41
+
42
+ /**
43
+ * The share of the machine the torrent thread draws in intervals that
44
+ * downloaded nothing: what comes off its spending before the rest is priced
45
+ * per downloaded byte.
46
+ */
47
+ #observedThreadBaseDraw = null;
48
+
49
+ /** @type {number[]} Recent readings behind that median. */
50
+ #threadBaseReadings = [];
51
+
52
+ /**
53
+ * The download limit the processor share last set, bytes per second;
54
+ * Infinity for none, null before the share ran. Said beside what the
55
+ * torrents actually moved.
56
+ */
57
+ downloadLimit = null;
58
+
36
59
  /** The previous reading of the whole machine taken while nothing was encoding. */
37
60
  #idleAvailabilitySample = null;
38
61
 
@@ -47,16 +70,21 @@ export class HostLoad {
47
70
  */
48
71
  fileLengthByKey = new Map();
49
72
 
50
- /** Seconds of this process's CPU per megabyte the torrent moves, once measured. */
73
+ /**
74
+ * The machine's seconds per megabyte downloaded, spent by the torrent thread
75
+ * (its own processor time divided by the cores, its base draw taken off),
76
+ * once measured. The torrent's work on other threads — libuv's pool, the main
77
+ * thread — is not in it. Read by the processor share and the quality offer.
78
+ */
51
79
  observedTorrentCostPerMegabyte = null;
52
80
 
53
81
  /** @type {number[]} Recent readings behind that median. */
54
82
  #torrentCostReadings = [];
55
83
 
56
84
  /**
57
- * The share of one core this process draws with nothing encoding and the
58
- * torrents moving nothing — the spending that would have happened anyway, and
59
- * which must come off a reading before the rest is called the torrent's.
85
+ * The share of the machine this process draws with nothing encoding and the
86
+ * torrents moving nothing — the spending that would have happened anyway.
87
+ * The processor share takes it off what the proxy has.
60
88
  */
61
89
  #observedBaseDraw = null;
62
90
 
@@ -89,6 +117,11 @@ export class HostLoad {
89
117
  /**
90
118
  * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
91
119
  */
120
+ /** The share of the machine this process draws doing nothing priced; zero until measured. */
121
+ get baseDraw() {
122
+ return this.#observedBaseDraw ?? 0;
123
+ }
124
+
92
125
  constructor(host) {
93
126
  this.#host = host;
94
127
  }
@@ -108,60 +141,100 @@ export class HostLoad {
108
141
  }
109
142
 
110
143
  /**
111
- * What the torrent itself costs this machine, per megabyte it moves.
144
+ * What the torrent costs this machine per megabyte it downloads, and what
145
+ * this process draws when it is doing nothing priced. One reading of the
146
+ * torrent totals feeds both.
147
+ *
148
+ * THE PRICE OF A BYTE is the torrent thread's own processor time against the
149
+ * bytes every torrent downloaded, both read by that thread in one moment, so
150
+ * they cover one interval. It is taken whether or not anything encodes: up to
151
+ * 2.97.7 it was taken only while nothing encoded, and an AVI re-encoded from
152
+ * the first second of its download never got a price, so the processor share
153
+ * never limited the download it was built for (field 2026-10-11: 0 limits on
154
+ * the AVI, 267 on the MKV, torrent-tv/meta#178). The thread's base draw —
155
+ * what it spends in intervals that downloaded nothing — comes off first, with
156
+ * the check of `encode/torrent-cost.js` that what is left is more than the
157
+ * base draw's own scatter; an interval that downloaded nothing is never a
158
+ * price. The torrent's work on other threads is not counted.
112
159
  *
113
- * Downloading, verifying every piece and pushing segments down a data channel
114
- * are work on the same box as the encoder, they scale with the file's own
115
- * bitrate, and the budget counts none of it. Measured on the addon host with
116
- * every encoder suspended, the machine was still 20-29 % busy.
160
+ * THE BASE DRAW OF THIS PROCESS is taken as before: only while nothing
161
+ * encodes and nothing downloads, and never across metadata parsing.
117
162
  *
118
- * Taken only while NOTHING is encoding, because that is the only moment the
119
- * spending can be attributed without arithmetic: what this process uses then
120
- * is the torrent's.
163
+ * @param {boolean} nothingEncoding
121
164
  */
122
- async #learnTorrentCost() {
123
- const activityBefore = this.#host.readMetadataActivity?.();
124
- const bytes = await this.#torrentBytesMoved();
125
- const now = {
126
- takenAt: Date.now(),
127
- cpuSeconds: this.#host.readProxyCpuSeconds(),
128
- bytes,
129
- metadata: this.#host.readMetadataActivity?.()
130
- };
131
- const previous = this.#idleLoadSample;
132
- this.#idleLoadSample = now;
133
- if (previous === null || now.bytes === null || previous.bytes === null) {
134
- return;
165
+ async #learnTorrentCost(nothingEncoding) {
166
+ if (this.#readingTorrent) return;
167
+ this.#readingTorrent = true;
168
+ try {
169
+ const activityBefore = this.#host.readMetadataActivity?.();
170
+ const totals = await this.#torrentTotals();
171
+ const now = {
172
+ takenAt: Date.now(),
173
+ cpuSeconds: this.#host.readProxyCpuSeconds(),
174
+ bytes: totals?.downloaded ?? null,
175
+ threadCpuSeconds: totals?.threadCpuSeconds ?? null,
176
+ metadata: this.#host.readMetadataActivity?.()
177
+ };
178
+ this.#learnThreadCost(now);
179
+ if (!nothingEncoding) {
180
+ this.#idleLoadSample = null;
181
+ return;
182
+ }
183
+ const previous = this.#idleLoadSample;
184
+ this.#idleLoadSample = now;
185
+ if (previous === null || now.bytes === null || previous.bytes === null || now.bytes !== previous.bytes) return;
186
+ // Process CPU includes metadata parsing; an interval holding it is not
187
+ // what this process draws doing nothing.
188
+ if (activityBefore?.active || now.metadata?.active || previous.metadata?.active ||
189
+ activityBefore?.epoch !== now.metadata?.epoch || previous.metadata?.epoch !== now.metadata?.epoch) return;
190
+ const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
191
+ // Divided by the cores: `process.cpuUsage()` adds up every thread, while
192
+ // everything this figure is set against is a share of the whole machine.
193
+ const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / Math.max(1, os.cpus().length);
194
+ this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
195
+ } finally {
196
+ this.#readingTorrent = false;
135
197
  }
136
- // Process CPU includes metadata parsing. Keep the sample as a baseline,
137
- // but never attribute an interval containing that work to torrent bytes.
138
- if (activityBefore?.active || now.metadata?.active || previous.metadata?.active ||
139
- activityBefore?.epoch !== now.metadata?.epoch || previous.metadata?.epoch !== now.metadata?.epoch) return;
198
+ }
199
+
200
+ /**
201
+ * One interval of the torrent thread: its base draw where it downloaded
202
+ * nothing, a price where it downloaded something, and what it moved against
203
+ * the limit in force, said while anything moves or is limited.
204
+ *
205
+ * @param {{ takenAt: number, bytes: number | null, threadCpuSeconds: number | null }} now
206
+ */
207
+ #learnThreadCost(now) {
208
+ const previous = this.#torrentSample;
209
+ this.#torrentSample = now;
210
+ if (previous === null || now.bytes === null || previous.bytes === null ||
211
+ now.threadCpuSeconds === null || previous.threadCpuSeconds === null) return;
140
212
  const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
141
213
  const megabytes = (now.bytes - previous.bytes) / 1e6;
142
- // Divided by the cores, because `process.cpuUsage()` adds up every thread
143
- // while everything this figure is later added to is measured in WALL
144
- // seconds per second of video. Left undivided on the four-core addon host
145
- // it overstated the torrent by four times, which on the field's own rung
146
- // was the difference between offering it and refusing it.
147
- const cores = Math.max(1, os.cpus().length);
148
- const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / cores;
214
+ // A torrent leaving the pool takes its bytes out of the total, and a new
215
+ // worker starts its clock again: such an interval says nothing.
216
+ if (!(elapsedSec > 0) || megabytes < 0 || now.threadCpuSeconds < previous.threadCpuSeconds) return;
217
+ const cpuSeconds = (now.threadCpuSeconds - previous.threadCpuSeconds) / Math.max(1, os.cpus().length);
218
+ const limit = this.downloadLimit;
219
+ if (megabytes > 0 || Number.isFinite(limit)) {
220
+ logger.info(
221
+ `torrent: downloaded ${(megabytes / elapsedSec).toFixed(2)} MB/s over ${elapsedSec.toFixed(1)}s, ` +
222
+ `limit ${Number.isFinite(limit) ? `${(limit / 1e6).toFixed(2)} MB/s` : "none"}; ` +
223
+ `its thread drew ${Math.round(cpuSeconds / elapsedSec * 100)}% of the machine`
224
+ );
225
+ }
149
226
  if (megabytes === 0) {
150
- // Nothing encoding and not one byte moved: whatever this process spent in
151
- // that interval, it spends whether or not there is a torrent. Measuring
152
- // it is what lets the next interval be attributed instead of divided
153
- // whole — see `encode/torrent-cost.js` for the readings that forced this.
154
- this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
227
+ this.#learnThreadBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
155
228
  return;
156
229
  }
157
230
  const costPerMegabyte = costPerMegabyteFrom({
158
231
  cpuSeconds,
159
232
  elapsedSeconds: elapsedSec,
160
233
  megabytes,
161
- baseDraw: this.#observedBaseDraw,
162
- // How much the draw's own readings disagree, which is how much of this
163
- // interval's remainder means nothing.
164
- drawScatter: scatterOf(this.#baseDrawReadings)
234
+ baseDraw: this.#observedThreadBaseDraw,
235
+ // How much the base draw's own readings disagree, which is how much of
236
+ // this interval's remainder means nothing.
237
+ drawScatter: scatterOf(this.#threadBaseReadings)
165
238
  });
166
239
  if (costPerMegabyte === null) {
167
240
  return;
@@ -174,9 +247,31 @@ export class HostLoad {
174
247
  }
175
248
  this.observedTorrentCostPerMegabyte = median;
176
249
  logger.info(
177
- `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
250
+ `host-load: the torrent thread costs ${(median * 1000).toFixed(1)}ms of the machine per downloaded MB ` +
178
251
  `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB, ` +
179
- `base draw ${((this.#observedBaseDraw ?? 0) * 100).toFixed(1)}% of a core already taken off)`
252
+ `its base draw ${((this.#observedThreadBaseDraw ?? 0) * 100).toFixed(1)}% of the machine taken off)`
253
+ );
254
+ }
255
+
256
+ /**
257
+ * What the torrent thread draws in an interval that downloaded nothing.
258
+ *
259
+ * @param {number | null} share - Of the machine, over the interval just read.
260
+ */
261
+ #learnThreadBaseDraw(share) {
262
+ if (share === null) {
263
+ return;
264
+ }
265
+ const readings = [...this.#threadBaseReadings, share].slice(-READINGS_KEPT);
266
+ this.#threadBaseReadings = readings;
267
+ const median = medianOf(readings);
268
+ if (!movedBeyondScatter(this.#observedThreadBaseDraw, median, readings)) {
269
+ return;
270
+ }
271
+ this.#observedThreadBaseDraw = median;
272
+ logger.info(
273
+ `host-load: the torrent thread draws ${(median * 100).toFixed(1)}% of the machine while it downloads nothing ` +
274
+ `(median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
180
275
  );
181
276
  }
182
277
 
@@ -303,7 +398,7 @@ export class HostLoad {
303
398
  * Record what this process draws when it is doing none of the work that gets
304
399
  * priced.
305
400
  *
306
- * @param {number | null} share - Of one core, over the interval just read.
401
+ * @param {number | null} share - Of the machine, over the interval just read.
307
402
  * @returns {void}
308
403
  */
309
404
  #learnBaseDraw(share) {
@@ -318,29 +413,31 @@ export class HostLoad {
318
413
  }
319
414
  this.#observedBaseDraw = median;
320
415
  logger.info(
321
- `host-load: this process draws ${(median * 100).toFixed(1)}% of a core with nothing encoding and ` +
416
+ `host-load: this process draws ${(median * 100).toFixed(1)}% of the machine with nothing encoding and ` +
322
417
  `nothing downloading (median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
323
418
  );
324
419
  }
325
420
 
326
421
  /**
327
- * Bytes this proxy's torrents have moved in total, or null when it cannot be
328
- * asked.
422
+ * The bytes every torrent has downloaded and the torrent thread's processor
423
+ * seconds, read together by that thread, or null when it cannot be asked.
424
+ * Downloaded bytes only: every one is verified against the piece hash and
425
+ * written to the store; a byte sent back to the swarm is neither.
329
426
  *
330
- * @returns {Promise<number | null>}
427
+ * @returns {Promise<{ downloaded: number | null, threadCpuSeconds: number | null } | null>}
331
428
  */
332
- async #torrentBytesMoved() {
429
+ async #torrentTotals() {
333
430
  if (typeof this.#host.getTorrentTotals !== "function") {
334
431
  return null;
335
432
  }
336
433
  try {
337
434
  const totals = await this.#host.getTorrentTotals();
338
- // Downloaded bytes only. Every one of them is verified against the piece
339
- // hash and written to the store; a byte sent back to the swarm is neither,
340
- // and adding the two would price both at whatever the mixture happened to
341
- // be on the day.
342
435
  const downloaded = Number(totals?.downloaded);
343
- return Number.isFinite(downloaded) ? downloaded : null;
436
+ const threadCpuSeconds = Number(totals?.threadCpuSeconds);
437
+ return {
438
+ downloaded: Number.isFinite(downloaded) ? downloaded : null,
439
+ threadCpuSeconds: Number.isFinite(threadCpuSeconds) ? threadCpuSeconds : null
440
+ };
344
441
  } catch {
345
442
  return null; // the pool is busy or gone; a reading missed is not a fault
346
443
  }
@@ -370,9 +467,7 @@ export class HostLoad {
370
467
  } finally {
371
468
  this.#reporting = false;
372
469
  }
373
- if (nothingEncoding) {
374
- await this.#learnTorrentCost();
375
- }
470
+ await this.#learnTorrentCost(nothingEncoding);
376
471
  }
377
472
 
378
473
  /**
@@ -381,7 +476,7 @@ export class HostLoad {
381
476
  * in force for ever: it refused every new encoder, and a refused encoder is
382
477
  * the only thing that would have taken the next reading.
383
478
  */
384
- async #learnIdleAvailability() {
479
+ async #learnIdleAvailability(watched, suspended) {
385
480
  const system = (await this.#host.readSystemCpu?.()) ?? null;
386
481
  const sample = { takenAt: Date.now(), system, proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
387
482
  const previous = this.#idleAvailabilitySample;
@@ -394,7 +489,36 @@ export class HostLoad {
394
489
  { takenAt: previous.takenAt, processCpuSeconds: 0, system: previous.system },
395
490
  { takenAt: sample.takenAt, processCpuSeconds: 0, system: sample.system }
396
491
  );
397
- this.#setAvailability(share, previous, sample);
492
+ const proxyShare = this.#setAvailability(share, previous, sample);
493
+ // Said while something is watched, so what is free while every encoder
494
+ // stands is seen beside what it is free for (torrent-tv/meta#178); a proxy
495
+ // nobody watches stays quiet.
496
+ if (share !== null && watched) {
497
+ await this.#sayMachine(share, proxyShare, 0, suspended);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * One line of the machine's reading, the same whether encoders run or stand.
503
+ * `available` is the share OTHER processes leave the proxy, what the offer
504
+ * and the processor share divide; `free` is what nothing used over the
505
+ * interval. Unknown stays `n/a`, never zero.
506
+ */
507
+ async #sayMachine(share, proxyShare, running, suspended) {
508
+ const machine = await this.#host.readMachineState();
509
+ const asPercent = (value) => (value === null || value === undefined ? "n/a" : `${Math.round(value * 100)}%`);
510
+ const free = Number.isFinite(share.systemShare) ? Math.max(0, 1 - share.systemShare) : null;
511
+ logger.info(
512
+ `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
513
+ `system=${asPercent(share.systemShare)} ` +
514
+ `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
515
+ `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
516
+ `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
517
+ ` over=${share.elapsedSec.toFixed(1)}s` +
518
+ // What the offer will multiply a prediction by, in the same line as the
519
+ // readings it comes from.
520
+ ` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}`
521
+ );
398
522
  }
399
523
 
400
524
  /**
@@ -428,7 +552,8 @@ export class HostLoad {
428
552
 
429
553
  /** @returns {Promise<boolean>} True when no encoder is running, which is when the torrent's cost can be taken. */
430
554
  async #reportMachineOnce() {
431
- const encoding = [...this.#host.outputs.values()].filter(
555
+ const outputs = [...this.#host.outputs.values()];
556
+ const encoding = outputs.filter(
432
557
  (session) => processCanBeSignalled(this.#host.runStateOf(session))
433
558
  );
434
559
  const runningNow = encoding.filter((session) => this.#host.runStateOf(session) !== ENCODE_RUN_STATE.SUSPENDED);
@@ -438,7 +563,8 @@ export class HostLoad {
438
563
  // in which every encoder was suspended, the torrent's price could have
439
564
  // been taken, and none was. What this process spends now is the download,
440
565
  // the verification, the piece store and the delivery. Item 7.
441
- await this.#learnIdleAvailability();
566
+ await this.#learnIdleAvailability(outputs.length > 0,
567
+ encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length);
442
568
  this.#hostLoadSample = null;
443
569
  return true;
444
570
  }
@@ -509,24 +635,12 @@ export class HostLoad {
509
635
  // they stopped).
510
636
  const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
511
637
  const running = encoding.length - suspended;
512
- const machine = await this.#host.readMachineState();
513
- const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
514
638
  // Kept for the quality offer, which predicts from a benchmark taken on a
515
639
  // QUIET host: the same reading that is printed here says how much of the
516
640
  // machine a new encoder could actually have. Only what nobody has been
517
641
  // charged for is subtracted — see `encode/available-share.js`.
518
642
  const proxyShare = this.#setAvailability(share, previous, sample);
519
- logger.info(
520
- `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
521
- `system=${asPercent(share.systemShare)} ` +
522
- `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
523
- `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
524
- `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
525
- ` over=${share.elapsedSec.toFixed(1)}s` +
526
- // What the offer will multiply a prediction by, in the same line as the
527
- // readings it comes from.
528
- ` available=${asPercent(this.hostAvailability.share)}`
529
- );
643
+ await this.#sayMachine(share, proxyShare, running, suspended);
530
644
  return false;
531
645
  }
532
646
 
@@ -536,8 +650,9 @@ export class HostLoad {
536
650
  *
537
651
  * A viewer consumes the file at its own byte rate, and every one of those
538
652
  * bytes is downloaded, verified and pushed by this process. Priced per
539
- * megabyte from readings taken while nothing was encoding, so the two
540
- * measurements do not contain each other. Zero while either term is unmeasured
653
+ * megabyte by the torrent thread's own processor time, which no encoder's
654
+ * reading contains, so the two do not contain each other; the torrent's
655
+ * work on other threads is not in it. Zero while either term is unmeasured
541
656
  * — a guess here would refuse rungs on arithmetic nobody performed.
542
657
  *
543
658
  * @param {HlsSession} session
@@ -97,6 +97,8 @@ 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 { downloadShare } from "../storage/processor-share.js";
101
+ import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
100
102
 
101
103
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
102
104
  // and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
@@ -193,6 +195,7 @@ export function wireOutputs({
193
195
  decodeCostModel = null,
194
196
  getSourceStats = null,
195
197
  setPriorityMap = null,
198
+ setDownloadRate = null,
196
199
  // What a second job costs on this host, measured at startup. Null when it
197
200
  // could not be measured, and then nothing is corrected — the alternative
198
201
  // is inventing a penalty, which is the same fault as inventing a fill rate.
@@ -342,7 +345,7 @@ export function wireOutputs({
342
345
  get encoders() { return parts.encoders; },
343
346
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
344
347
  productionFailed: (output) => parts.renditions.noteProductionFailed(output),
345
- encodersChanged: () => onEncodersChanged?.(),
348
+ encodersChanged: () => { parts.applyDownloadShare?.(); onEncodersChanged?.(); },
346
349
  });
347
350
  // How much film is ready in front of the viewers, said; and the spare soundtracks fetched once it is full.
348
351
  parts.cushion = new CushionReport({
@@ -648,6 +651,34 @@ export function wireOutputs({
648
651
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
649
652
  parts.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
650
653
  parts.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
654
+ parts.setDownloadRate = typeof setDownloadRate === "function" ? setDownloadRate : null;
655
+ // HOW FAST THE TORRENTS MAY DOWNLOAD: the processor the encoders leave
656
+ // (`storage/processor-share.js`, torrent-tv/meta#178). Worked out again
657
+ // when the machine is read and when an encoder starts or ends, over every
658
+ // watched file of every torrent.
659
+ parts.applyDownloadShare = () => {
660
+ if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
661
+ const files = new Map();
662
+ for (const output of parts.outputs.values()) {
663
+ if (!(parts.viewers?.forOutput(output)?.size > 0)) continue;
664
+ const key = output.file.key;
665
+ const length = Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(key));
666
+ const file = files.get(key) ?? { bytesPerFilmSecond: length / Number(output.file.durationSeconds), encodeCostSec: 0 };
667
+ const state = parts.encodeRuns.runStateOf(output);
668
+ if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
669
+ file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
670
+ }
671
+ files.set(key, file);
672
+ }
673
+ const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
674
+ const share = downloadShare({ availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
675
+ baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
676
+ // Kept beside the readings it is judged against: what the torrents moved
677
+ // over each interval is said next to the limit in force (meta#178).
678
+ parts.hostLoad.downloadLimit = share.bytesPerSecond;
679
+ void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
680
+ `over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
681
+ };
651
682
  parts.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
652
683
  // Seconds of film per second when the picture is COPIED, measured at
653
684
  // startup. Nothing else prices that branch: the other benchmarks measure
@@ -980,6 +1011,8 @@ export function wireOutputs({
980
1011
  openOf: (output) => parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "") ?? null,
981
1012
  urgent: (output, index) => parts.encodeOrchestrator.wantedSegmentsOn(output.outputKey).some(window =>
982
1013
  index >= window.from && index <= window.to && window.urgent === true),
1014
+ // The piece's rank in the output's own map, said with an input's state.
1015
+ rankAt: (output, index) => parts.encodeOrchestrator?.rankAt?.(output.outputKey ?? "", index) ?? null,
983
1016
  log: line => logger.info(line),
984
1017
  changed: (output, result) => {
985
1018
  if (result.kind === "terminal") {
@@ -1060,7 +1093,7 @@ export function wireOutputs({
1060
1093
  liveConsumers: (...args) => parts.renditions.liveConsumers(...args),
1061
1094
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
1062
1095
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
1063
- reportHostLoad: (...args) => parts.hostLoad.reportHostLoad(...args),
1096
+ reportHostLoad: async (...args) => { await parts.hostLoad.reportHostLoad(...args); parts.applyDownloadShare?.(); },
1064
1097
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
1065
1098
  sampleDownloadRates: (...args) => parts.hostLoad.sampleDownloadRates(...args),
1066
1099
  get encodeCost() { return parts.encodeCost; },
@@ -0,0 +1,57 @@
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 needed 2.3 s
6
+ * of one core 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, every term a share of the WHOLE MACHINE. The processor the proxy
12
+ * has, `C`, is the share other processes leave (`encode/available-share.js`)
13
+ * less what this process draws doing nothing priced. Every watched file `f` of
14
+ * every torrent advances at a film speed `s`: its running encoders take `s·k_f`
15
+ * (`k_f` the machine's seconds per second of film, the inverse of the encode
16
+ * speed) and its download `s·w_f·c` (`w_f` its bytes per second of film, `c`
17
+ * the machine's seconds per byte the torrent thread spends). One speed for all
18
+ * is the division under which the slowest file is as fast as it can be:
19
+ *
20
+ * s* = C / Σ_f (k_f + w_f·c), download rate = max(s*, 1) · Σ_f w_f
21
+ *
22
+ * Up to 2.97.7 `C` was counted in cores while `k` and `c` were shares of the
23
+ * machine, so on the four-core addon host the limit was about four times
24
+ * looser than this model.
25
+ *
26
+ * Downloading faster than `s*·w` only piles bytes ahead while the encoders
27
+ * starve; what is fetched first within the rate is the priority map's order.
28
+ * The rate never falls below the films' own byte rate, `Σ w`, because below it
29
+ * the viewer stops whatever the encoders get. Where `s* < 1` or the swarm
30
+ * gives less than the rate, stops cannot be avoided, and the start forecast,
31
+ * reading the speeds measured under this rate, waits for them at the start
32
+ * and after a seek. With nothing encoding `k` is zero and the download has the
33
+ * processor. A term nobody has measured contributes nothing: an unmeasured
34
+ * price of a byte leaves the download unlimited.
35
+ */
36
+
37
+ /**
38
+ * @param {object} params
39
+ * @param {number} params.availableShare - The share of the machine other processes leave, 0..1.
40
+ * @param {number} params.baseDraw - The share of the machine this process draws doing nothing priced.
41
+ * @param {number | null} params.costPerMegabyte - The machine's seconds per megabyte the torrent moves.
42
+ * @param {{ bytesPerFilmSecond: number, encodeCostSec: number }[]} params.files - Watched files.
43
+ * @returns {{ filmSpeed: number, bytesPerSecond: number, capacity: number }}
44
+ * `capacity` is a share of the machine; `bytesPerSecond` is Infinity where
45
+ * the download is not limited.
46
+ */
47
+ export function downloadShare({ availableShare, baseDraw, costPerMegabyte, files }) {
48
+ const capacity = Math.max(0, availableShare - baseDraw);
49
+ // A byte whose price is not measured, or costs nothing, takes nothing from
50
+ // the encoders: there is then no share to keep the download to.
51
+ const perByte = Number.isFinite(costPerMegabyte) && costPerMegabyte > 0 ? costPerMegabyte / 1e6 : 0;
52
+ const bytes = files.reduce((sum, file) => sum + file.bytesPerFilmSecond, 0);
53
+ const demand = files.reduce((sum, file) => sum + file.encodeCostSec + file.bytesPerFilmSecond * perByte, 0);
54
+ const filmSpeed = capacity / demand;
55
+ const bytesPerSecond = perByte > 0 ? Math.max(filmSpeed, 1) * bytes : Infinity;
56
+ return { filmSpeed, capacity, bytesPerSecond: Number.isFinite(bytesPerSecond) && bytes > 0 ? bytesPerSecond : Infinity };
57
+ }
@@ -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). */
@@ -419,7 +419,11 @@ async function runCommand(command, params, id) {
419
419
  downloaded += Number.isFinite(gotBytes) ? gotBytes : 0;
420
420
  uploaded += Number.isFinite(sentBytes) ? sentBytes : 0;
421
421
  }
422
- return { downloaded, uploaded };
422
+ // This thread's own processor time, read in the same moment as the
423
+ // bytes, so the price of a downloaded byte is taken over one interval
424
+ // whether or not anything encodes (torrent-tv/meta#178).
425
+ const usage = process.threadCpuUsage();
426
+ return { downloaded, uploaded, threadCpuSeconds: (usage.user + usage.system) / 1e6 };
423
427
  }
424
428
 
425
429
 
@@ -476,6 +480,11 @@ async function runCommand(command, params, id) {
476
480
  return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
477
481
  }
478
482
 
483
+ case Command.DOWNLOAD_RATE: {
484
+ pool.setDownloadRate(params.bytesPerSecond, params.because);
485
+ return true;
486
+ }
487
+
479
488
  case Command.READ_RANGE: {
480
489
  // Streams its own reply; the caller's promise resolves once the body has
481
490
  // been fully sent, which is what lets the client await completion.