@torrent-tv/proxy 2.97.7 → 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.7",
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": {
@@ -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,7 +117,7 @@ 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
  */
92
- /** The share of one core this process draws doing nothing priced; zero until measured. */
120
+ /** The share of the machine this process draws doing nothing priced; zero until measured. */
93
121
  get baseDraw() {
94
122
  return this.#observedBaseDraw ?? 0;
95
123
  }
@@ -113,60 +141,100 @@ export class HostLoad {
113
141
  }
114
142
 
115
143
  /**
116
- * 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.
117
147
  *
118
- * Downloading, verifying every piece and pushing segments down a data channel
119
- * are work on the same box as the encoder, they scale with the file's own
120
- * bitrate, and the budget counts none of it. Measured on the addon host with
121
- * every encoder suspended, the machine was still 20-29 % busy.
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.
122
159
  *
123
- * Taken only while NOTHING is encoding, because that is the only moment the
124
- * spending can be attributed without arithmetic: what this process uses then
125
- * is the torrent's.
160
+ * THE BASE DRAW OF THIS PROCESS is taken as before: only while nothing
161
+ * encodes and nothing downloads, and never across metadata parsing.
162
+ *
163
+ * @param {boolean} nothingEncoding
126
164
  */
127
- async #learnTorrentCost() {
128
- const activityBefore = this.#host.readMetadataActivity?.();
129
- const bytes = await this.#torrentBytesMoved();
130
- const now = {
131
- takenAt: Date.now(),
132
- cpuSeconds: this.#host.readProxyCpuSeconds(),
133
- bytes,
134
- metadata: this.#host.readMetadataActivity?.()
135
- };
136
- const previous = this.#idleLoadSample;
137
- this.#idleLoadSample = now;
138
- if (previous === null || now.bytes === null || previous.bytes === null) {
139
- 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;
140
197
  }
141
- // Process CPU includes metadata parsing. Keep the sample as a baseline,
142
- // but never attribute an interval containing that work to torrent bytes.
143
- if (activityBefore?.active || now.metadata?.active || previous.metadata?.active ||
144
- 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;
145
212
  const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
146
213
  const megabytes = (now.bytes - previous.bytes) / 1e6;
147
- // Divided by the cores, because `process.cpuUsage()` adds up every thread
148
- // while everything this figure is later added to is measured in WALL
149
- // seconds per second of video. Left undivided on the four-core addon host
150
- // it overstated the torrent by four times, which on the field's own rung
151
- // was the difference between offering it and refusing it.
152
- const cores = Math.max(1, os.cpus().length);
153
- 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
+ }
154
226
  if (megabytes === 0) {
155
- // Nothing encoding and not one byte moved: whatever this process spent in
156
- // that interval, it spends whether or not there is a torrent. Measuring
157
- // it is what lets the next interval be attributed instead of divided
158
- // whole — see `encode/torrent-cost.js` for the readings that forced this.
159
- this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
227
+ this.#learnThreadBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
160
228
  return;
161
229
  }
162
230
  const costPerMegabyte = costPerMegabyteFrom({
163
231
  cpuSeconds,
164
232
  elapsedSeconds: elapsedSec,
165
233
  megabytes,
166
- baseDraw: this.#observedBaseDraw,
167
- // How much the draw's own readings disagree, which is how much of this
168
- // interval's remainder means nothing.
169
- 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)
170
238
  });
171
239
  if (costPerMegabyte === null) {
172
240
  return;
@@ -179,9 +247,31 @@ export class HostLoad {
179
247
  }
180
248
  this.observedTorrentCostPerMegabyte = median;
181
249
  logger.info(
182
- `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 ` +
183
251
  `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB, ` +
184
- `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)}%)`
185
275
  );
186
276
  }
187
277
 
@@ -308,7 +398,7 @@ export class HostLoad {
308
398
  * Record what this process draws when it is doing none of the work that gets
309
399
  * priced.
310
400
  *
311
- * @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.
312
402
  * @returns {void}
313
403
  */
314
404
  #learnBaseDraw(share) {
@@ -323,29 +413,31 @@ export class HostLoad {
323
413
  }
324
414
  this.#observedBaseDraw = median;
325
415
  logger.info(
326
- `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 ` +
327
417
  `nothing downloading (median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
328
418
  );
329
419
  }
330
420
 
331
421
  /**
332
- * Bytes this proxy's torrents have moved in total, or null when it cannot be
333
- * 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.
334
426
  *
335
- * @returns {Promise<number | null>}
427
+ * @returns {Promise<{ downloaded: number | null, threadCpuSeconds: number | null } | null>}
336
428
  */
337
- async #torrentBytesMoved() {
429
+ async #torrentTotals() {
338
430
  if (typeof this.#host.getTorrentTotals !== "function") {
339
431
  return null;
340
432
  }
341
433
  try {
342
434
  const totals = await this.#host.getTorrentTotals();
343
- // Downloaded bytes only. Every one of them is verified against the piece
344
- // hash and written to the store; a byte sent back to the swarm is neither,
345
- // and adding the two would price both at whatever the mixture happened to
346
- // be on the day.
347
435
  const downloaded = Number(totals?.downloaded);
348
- 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
+ };
349
441
  } catch {
350
442
  return null; // the pool is busy or gone; a reading missed is not a fault
351
443
  }
@@ -375,9 +467,7 @@ export class HostLoad {
375
467
  } finally {
376
468
  this.#reporting = false;
377
469
  }
378
- if (nothingEncoding) {
379
- await this.#learnTorrentCost();
380
- }
470
+ await this.#learnTorrentCost(nothingEncoding);
381
471
  }
382
472
 
383
473
  /**
@@ -386,7 +476,7 @@ export class HostLoad {
386
476
  * in force for ever: it refused every new encoder, and a refused encoder is
387
477
  * the only thing that would have taken the next reading.
388
478
  */
389
- async #learnIdleAvailability() {
479
+ async #learnIdleAvailability(watched, suspended) {
390
480
  const system = (await this.#host.readSystemCpu?.()) ?? null;
391
481
  const sample = { takenAt: Date.now(), system, proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
392
482
  const previous = this.#idleAvailabilitySample;
@@ -399,7 +489,36 @@ export class HostLoad {
399
489
  { takenAt: previous.takenAt, processCpuSeconds: 0, system: previous.system },
400
490
  { takenAt: sample.takenAt, processCpuSeconds: 0, system: sample.system }
401
491
  );
402
- 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
+ );
403
522
  }
404
523
 
405
524
  /**
@@ -433,7 +552,8 @@ export class HostLoad {
433
552
 
434
553
  /** @returns {Promise<boolean>} True when no encoder is running, which is when the torrent's cost can be taken. */
435
554
  async #reportMachineOnce() {
436
- const encoding = [...this.#host.outputs.values()].filter(
555
+ const outputs = [...this.#host.outputs.values()];
556
+ const encoding = outputs.filter(
437
557
  (session) => processCanBeSignalled(this.#host.runStateOf(session))
438
558
  );
439
559
  const runningNow = encoding.filter((session) => this.#host.runStateOf(session) !== ENCODE_RUN_STATE.SUSPENDED);
@@ -443,7 +563,8 @@ export class HostLoad {
443
563
  // in which every encoder was suspended, the torrent's price could have
444
564
  // been taken, and none was. What this process spends now is the download,
445
565
  // the verification, the piece store and the delivery. Item 7.
446
- await this.#learnIdleAvailability();
566
+ await this.#learnIdleAvailability(outputs.length > 0,
567
+ encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length);
447
568
  this.#hostLoadSample = null;
448
569
  return true;
449
570
  }
@@ -514,24 +635,12 @@ export class HostLoad {
514
635
  // they stopped).
515
636
  const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
516
637
  const running = encoding.length - suspended;
517
- const machine = await this.#host.readMachineState();
518
- const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
519
638
  // Kept for the quality offer, which predicts from a benchmark taken on a
520
639
  // QUIET host: the same reading that is printed here says how much of the
521
640
  // machine a new encoder could actually have. Only what nobody has been
522
641
  // charged for is subtracted — see `encode/available-share.js`.
523
642
  const proxyShare = this.#setAvailability(share, previous, sample);
524
- logger.info(
525
- `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
526
- `system=${asPercent(share.systemShare)} ` +
527
- `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
528
- `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
529
- `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
530
- ` over=${share.elapsedSec.toFixed(1)}s` +
531
- // What the offer will multiply a prediction by, in the same line as the
532
- // readings it comes from.
533
- ` available=${asPercent(this.hostAvailability.share)}`
534
- );
643
+ await this.#sayMachine(share, proxyShare, running, suspended);
535
644
  return false;
536
645
  }
537
646
 
@@ -541,8 +650,9 @@ export class HostLoad {
541
650
  *
542
651
  * A viewer consumes the file at its own byte rate, and every one of those
543
652
  * bytes is downloaded, verified and pushed by this process. Priced per
544
- * megabyte from readings taken while nothing was encoding, so the two
545
- * 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
546
656
  * — a guess here would refuse rungs on arithmetic nobody performed.
547
657
  *
548
658
  * @param {HlsSession} session
@@ -97,7 +97,6 @@ import path from "node:path";
97
97
  import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
98
98
  import { configurationKeyOf } from "../encode/fingerprint.js";
99
99
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
100
- import os from "node:os";
101
100
  import { downloadShare } from "../storage/processor-share.js";
102
101
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
103
102
 
@@ -672,10 +671,13 @@ export function wireOutputs({
672
671
  files.set(key, file);
673
672
  }
674
673
  const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
675
- const share = downloadShare({ cores: os.cpus().length, availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
674
+ const share = downloadShare({ availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
676
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;
677
679
  void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
678
- `over ${watched.length} file(s), ${share.capacity.toFixed(2)} core(s) for the proxy`)).catch(() => {});
680
+ `over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
679
681
  };
680
682
  parts.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
681
683
  // Seconds of film per second when the picture is COPIED, measured at
@@ -1009,6 +1011,8 @@ export function wireOutputs({
1009
1011
  openOf: (output) => parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "") ?? null,
1010
1012
  urgent: (output, index) => parts.encodeOrchestrator.wantedSegmentsOn(output.outputKey).some(window =>
1011
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,
1012
1016
  log: line => logger.info(line),
1013
1017
  changed: (output, result) => {
1014
1018
  if (result.kind === "terminal") {
@@ -2,22 +2,27 @@
2
2
  * @file How fast the torrents may download, so that downloading does not take
3
3
  * the processor the encoders need (torrent-tv/meta#178).
4
4
  *
5
- * Field 2026-10-11, Home Assistant: an AVI re-encoded at 720x400 needs 2.3 s
6
- * of processor per second of film. While the torrent downloaded 4.5-9.8 MB/s —
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
7
  * mostly far ahead of the viewer — its thread took 56-63 % of a core, ffmpeg
8
8
  * got 176-182 %, below real time, and the viewer saw the spinner; once the
9
9
  * download stopped ffmpeg got 216-261 % and there were no stops.
10
10
  *
11
- * THE MODEL. The processor the proxy has, `C`, is what other processes leave
12
- * of every core, less what the proxy draws doing nothing priced. Every watched
13
- * file `f` of every torrent advances at a film speed `s`: its encoders take
14
- * `s·k_f` (seconds of processor per second of film, as measured for the
15
- * encoders running now) and its download `s·w_f·c` (`w_f` its bytes per second
16
- * of film, `c` the measured processor per byte moved). One speed for all is
17
- * the division under which the slowest file is as fast as it can be:
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:
18
19
  *
19
20
  * s* = C / Σ_f (k_f + w_f·c), download rate = max(s*, 1) · Σ_f w_f
20
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
+ *
21
26
  * Downloading faster than `s*·w` only piles bytes ahead while the encoders
22
27
  * starve; what is fetched first within the rate is the priority map's order.
23
28
  * The rate never falls below the films' own byte rate, `Σ w`, because below it
@@ -31,16 +36,16 @@
31
36
 
32
37
  /**
33
38
  * @param {object} params
34
- * @param {number} params.cores
35
39
  * @param {number} params.availableShare - The share of the machine other processes leave, 0..1.
36
- * @param {number} params.baseDraw - Processor the proxy draws doing nothing priced, in cores.
37
- * @param {number | null} params.costPerMegabyte - Processor seconds per megabyte the torrent moves.
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.
38
42
  * @param {{ bytesPerFilmSecond: number, encodeCostSec: number }[]} params.files - Watched files.
39
43
  * @returns {{ filmSpeed: number, bytesPerSecond: number, capacity: number }}
40
- * `bytesPerSecond` is Infinity where the download is not limited.
44
+ * `capacity` is a share of the machine; `bytesPerSecond` is Infinity where
45
+ * the download is not limited.
41
46
  */
42
- export function downloadShare({ cores, availableShare, baseDraw, costPerMegabyte, files }) {
43
- const capacity = Math.max(0, cores * availableShare - baseDraw);
47
+ export function downloadShare({ availableShare, baseDraw, costPerMegabyte, files }) {
48
+ const capacity = Math.max(0, availableShare - baseDraw);
44
49
  // A byte whose price is not measured, or costs nothing, takes nothing from
45
50
  // the encoders: there is then no share to keep the download to.
46
51
  const perByte = Number.isFinite(costPerMegabyte) && costPerMegabyte > 0 ? costPerMegabyte / 1e6 : 0;
@@ -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