@torrent-tv/proxy 2.97.7 → 2.97.9

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.9",
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,32 @@ 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, as the processor share and
75
+ * the quality offer read it. Null: no established price, so the download is
76
+ * not limited and the offer counts no torrent cost.
77
+ *
78
+ * Not set from the torrent thread's reading below. On 2.97.8 that reading
79
+ * carried the thread's other work — 1287, 217 and 146 ms per MB at the AVI's
80
+ * start, median 263 against 12.5 on the MKV — and held the AVI download at
81
+ * its own film rate; until the price is established it is kept for the log
82
+ * only (torrent-tv/meta#178, control run).
83
+ */
51
84
  observedTorrentCostPerMegabyte = null;
52
85
 
86
+ /**
87
+ * The torrent thread's processor per downloaded megabyte, its base draw taken
88
+ * off: a reading for the log, used by nothing.
89
+ */
90
+ torrentThreadCostPerMegabyte = null;
91
+
53
92
  /** @type {number[]} Recent readings behind that median. */
54
93
  #torrentCostReadings = [];
55
94
 
56
95
  /**
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.
96
+ * The share of the machine this process draws with nothing encoding and the
97
+ * torrents moving nothing — the spending that would have happened anyway.
98
+ * The processor share takes it off what the proxy has.
60
99
  */
61
100
  #observedBaseDraw = null;
62
101
 
@@ -89,7 +128,7 @@ export class HostLoad {
89
128
  /**
90
129
  * @param {object} host - `liveRunsOf`, `runStateOf`, the readings of `encode/host-load.js` (`readMachineState`, `readProcessCpuSeconds`, `readProxyCpuSeconds`, `readSystemCpu`, `shareOfMachine`), `getSourceStats`, `getTorrentTotals`, `outputs`
91
130
  */
92
- /** The share of one core this process draws doing nothing priced; zero until measured. */
131
+ /** The share of the machine this process draws doing nothing priced; zero until measured. */
93
132
  get baseDraw() {
94
133
  return this.#observedBaseDraw ?? 0;
95
134
  }
@@ -113,60 +152,100 @@ export class HostLoad {
113
152
  }
114
153
 
115
154
  /**
116
- * What the torrent itself costs this machine, per megabyte it moves.
155
+ * What the torrent costs this machine per megabyte it downloads, and what
156
+ * this process draws when it is doing nothing priced. One reading of the
157
+ * torrent totals feeds both.
158
+ *
159
+ * THE PRICE OF A BYTE is the torrent thread's own processor time against the
160
+ * bytes every torrent downloaded, both read by that thread in one moment, so
161
+ * they cover one interval. It is taken whether or not anything encodes: up to
162
+ * 2.97.7 it was taken only while nothing encoded, and an AVI re-encoded from
163
+ * the first second of its download never got a price, so the processor share
164
+ * never limited the download it was built for (field 2026-10-11: 0 limits on
165
+ * the AVI, 267 on the MKV, torrent-tv/meta#178). The thread's base draw —
166
+ * what it spends in intervals that downloaded nothing — comes off first, with
167
+ * the check of `encode/torrent-cost.js` that what is left is more than the
168
+ * base draw's own scatter; an interval that downloaded nothing is never a
169
+ * price. The torrent's work on other threads is not counted.
117
170
  *
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.
171
+ * THE BASE DRAW OF THIS PROCESS is taken as before: only while nothing
172
+ * encodes and nothing downloads, and never across metadata parsing.
122
173
  *
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.
174
+ * @param {boolean} nothingEncoding
126
175
  */
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;
176
+ async #learnTorrentCost(nothingEncoding) {
177
+ if (this.#readingTorrent) return;
178
+ this.#readingTorrent = true;
179
+ try {
180
+ const activityBefore = this.#host.readMetadataActivity?.();
181
+ const totals = await this.#torrentTotals();
182
+ const now = {
183
+ takenAt: Date.now(),
184
+ cpuSeconds: this.#host.readProxyCpuSeconds(),
185
+ bytes: totals?.downloaded ?? null,
186
+ threadCpuSeconds: totals?.threadCpuSeconds ?? null,
187
+ metadata: this.#host.readMetadataActivity?.()
188
+ };
189
+ this.#learnThreadCost(now);
190
+ if (!nothingEncoding) {
191
+ this.#idleLoadSample = null;
192
+ return;
193
+ }
194
+ const previous = this.#idleLoadSample;
195
+ this.#idleLoadSample = now;
196
+ if (previous === null || now.bytes === null || previous.bytes === null || now.bytes !== previous.bytes) return;
197
+ // Process CPU includes metadata parsing; an interval holding it is not
198
+ // what this process draws doing nothing.
199
+ if (activityBefore?.active || now.metadata?.active || previous.metadata?.active ||
200
+ activityBefore?.epoch !== now.metadata?.epoch || previous.metadata?.epoch !== now.metadata?.epoch) return;
201
+ const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
202
+ // Divided by the cores: `process.cpuUsage()` adds up every thread, while
203
+ // everything this figure is set against is a share of the whole machine.
204
+ const cpuSeconds = (now.cpuSeconds - previous.cpuSeconds) / Math.max(1, os.cpus().length);
205
+ this.#learnBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
206
+ } finally {
207
+ this.#readingTorrent = false;
140
208
  }
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;
209
+ }
210
+
211
+ /**
212
+ * One interval of the torrent thread: its base draw where it downloaded
213
+ * nothing, a price where it downloaded something, and what it moved against
214
+ * the limit in force, said while anything moves or is limited.
215
+ *
216
+ * @param {{ takenAt: number, bytes: number | null, threadCpuSeconds: number | null }} now
217
+ */
218
+ #learnThreadCost(now) {
219
+ const previous = this.#torrentSample;
220
+ this.#torrentSample = now;
221
+ if (previous === null || now.bytes === null || previous.bytes === null ||
222
+ now.threadCpuSeconds === null || previous.threadCpuSeconds === null) return;
145
223
  const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
146
224
  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;
225
+ // A torrent leaving the pool takes its bytes out of the total, and a new
226
+ // worker starts its clock again: such an interval says nothing.
227
+ if (!(elapsedSec > 0) || megabytes < 0 || now.threadCpuSeconds < previous.threadCpuSeconds) return;
228
+ const cpuSeconds = (now.threadCpuSeconds - previous.threadCpuSeconds) / Math.max(1, os.cpus().length);
229
+ const limit = this.downloadLimit;
230
+ if (megabytes > 0 || Number.isFinite(limit)) {
231
+ logger.info(
232
+ `torrent: downloaded ${(megabytes / elapsedSec).toFixed(2)} MB/s over ${elapsedSec.toFixed(1)}s, ` +
233
+ `limit ${Number.isFinite(limit) ? `${(limit / 1e6).toFixed(2)} MB/s` : "none"}; ` +
234
+ `its thread drew ${Math.round(cpuSeconds / elapsedSec * 100)}% of the machine`
235
+ );
236
+ }
154
237
  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 }));
238
+ this.#learnThreadBaseDraw(baseDrawFrom({ cpuSeconds, elapsedSeconds: elapsedSec }));
160
239
  return;
161
240
  }
162
241
  const costPerMegabyte = costPerMegabyteFrom({
163
242
  cpuSeconds,
164
243
  elapsedSeconds: elapsedSec,
165
244
  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)
245
+ baseDraw: this.#observedThreadBaseDraw,
246
+ // How much the base draw's own readings disagree, which is how much of
247
+ // this interval's remainder means nothing.
248
+ drawScatter: scatterOf(this.#threadBaseReadings)
170
249
  });
171
250
  if (costPerMegabyte === null) {
172
251
  return;
@@ -174,14 +253,37 @@ export class HostLoad {
174
253
  const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-READINGS_KEPT);
175
254
  this.#torrentCostReadings = readings;
176
255
  const median = medianOf(readings);
177
- if (!movedBeyondScatter(this.observedTorrentCostPerMegabyte, median, readings)) {
256
+ if (!movedBeyondScatter(this.torrentThreadCostPerMegabyte, median, readings)) {
178
257
  return;
179
258
  }
180
- this.observedTorrentCostPerMegabyte = median;
259
+ this.torrentThreadCostPerMegabyte = median;
181
260
  logger.info(
182
- `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
261
+ `host-load: the torrent thread costs ${(median * 1000).toFixed(1)}ms of the machine per downloaded MB ` +
183
262
  `(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)`
263
+ `its base draw ${((this.#observedThreadBaseDraw ?? 0) * 100).toFixed(1)}% of the machine taken off; ` +
264
+ `for the log only, not the download limit or the quality offer)`
265
+ );
266
+ }
267
+
268
+ /**
269
+ * What the torrent thread draws in an interval that downloaded nothing.
270
+ *
271
+ * @param {number | null} share - Of the machine, over the interval just read.
272
+ */
273
+ #learnThreadBaseDraw(share) {
274
+ if (share === null) {
275
+ return;
276
+ }
277
+ const readings = [...this.#threadBaseReadings, share].slice(-READINGS_KEPT);
278
+ this.#threadBaseReadings = readings;
279
+ const median = medianOf(readings);
280
+ if (!movedBeyondScatter(this.#observedThreadBaseDraw, median, readings)) {
281
+ return;
282
+ }
283
+ this.#observedThreadBaseDraw = median;
284
+ logger.info(
285
+ `host-load: the torrent thread draws ${(median * 100).toFixed(1)}% of the machine while it downloads nothing ` +
286
+ `(median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
185
287
  );
186
288
  }
187
289
 
@@ -308,7 +410,7 @@ export class HostLoad {
308
410
  * Record what this process draws when it is doing none of the work that gets
309
411
  * priced.
310
412
  *
311
- * @param {number | null} share - Of one core, over the interval just read.
413
+ * @param {number | null} share - Of the machine, over the interval just read.
312
414
  * @returns {void}
313
415
  */
314
416
  #learnBaseDraw(share) {
@@ -323,29 +425,31 @@ export class HostLoad {
323
425
  }
324
426
  this.#observedBaseDraw = median;
325
427
  logger.info(
326
- `host-load: this process draws ${(median * 100).toFixed(1)}% of a core with nothing encoding and ` +
428
+ `host-load: this process draws ${(median * 100).toFixed(1)}% of the machine with nothing encoding and ` +
327
429
  `nothing downloading (median of ${readings.length}, latest ${(share * 100).toFixed(1)}%)`
328
430
  );
329
431
  }
330
432
 
331
433
  /**
332
- * Bytes this proxy's torrents have moved in total, or null when it cannot be
333
- * asked.
434
+ * The bytes every torrent has downloaded and the torrent thread's processor
435
+ * seconds, read together by that thread, or null when it cannot be asked.
436
+ * Downloaded bytes only: every one is verified against the piece hash and
437
+ * written to the store; a byte sent back to the swarm is neither.
334
438
  *
335
- * @returns {Promise<number | null>}
439
+ * @returns {Promise<{ downloaded: number | null, threadCpuSeconds: number | null } | null>}
336
440
  */
337
- async #torrentBytesMoved() {
441
+ async #torrentTotals() {
338
442
  if (typeof this.#host.getTorrentTotals !== "function") {
339
443
  return null;
340
444
  }
341
445
  try {
342
446
  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
447
  const downloaded = Number(totals?.downloaded);
348
- return Number.isFinite(downloaded) ? downloaded : null;
448
+ const threadCpuSeconds = Number(totals?.threadCpuSeconds);
449
+ return {
450
+ downloaded: Number.isFinite(downloaded) ? downloaded : null,
451
+ threadCpuSeconds: Number.isFinite(threadCpuSeconds) ? threadCpuSeconds : null
452
+ };
349
453
  } catch {
350
454
  return null; // the pool is busy or gone; a reading missed is not a fault
351
455
  }
@@ -375,9 +479,7 @@ export class HostLoad {
375
479
  } finally {
376
480
  this.#reporting = false;
377
481
  }
378
- if (nothingEncoding) {
379
- await this.#learnTorrentCost();
380
- }
482
+ await this.#learnTorrentCost(nothingEncoding);
381
483
  }
382
484
 
383
485
  /**
@@ -386,7 +488,7 @@ export class HostLoad {
386
488
  * in force for ever: it refused every new encoder, and a refused encoder is
387
489
  * the only thing that would have taken the next reading.
388
490
  */
389
- async #learnIdleAvailability() {
491
+ async #learnIdleAvailability(watched, suspended) {
390
492
  const system = (await this.#host.readSystemCpu?.()) ?? null;
391
493
  const sample = { takenAt: Date.now(), system, proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
392
494
  const previous = this.#idleAvailabilitySample;
@@ -399,7 +501,36 @@ export class HostLoad {
399
501
  { takenAt: previous.takenAt, processCpuSeconds: 0, system: previous.system },
400
502
  { takenAt: sample.takenAt, processCpuSeconds: 0, system: sample.system }
401
503
  );
402
- this.#setAvailability(share, previous, sample);
504
+ const proxyShare = this.#setAvailability(share, previous, sample);
505
+ // Said while something is watched, so what is free while every encoder
506
+ // stands is seen beside what it is free for (torrent-tv/meta#178); a proxy
507
+ // nobody watches stays quiet.
508
+ if (share !== null && watched) {
509
+ await this.#sayMachine(share, proxyShare, 0, suspended);
510
+ }
511
+ }
512
+
513
+ /**
514
+ * One line of the machine's reading, the same whether encoders run or stand.
515
+ * `available` is the share OTHER processes leave the proxy, what the offer
516
+ * and the processor share divide; `free` is what nothing used over the
517
+ * interval. Unknown stays `n/a`, never zero.
518
+ */
519
+ async #sayMachine(share, proxyShare, running, suspended) {
520
+ const machine = await this.#host.readMachineState();
521
+ const asPercent = (value) => (value === null || value === undefined ? "n/a" : `${Math.round(value * 100)}%`);
522
+ const free = Number.isFinite(share.systemShare) ? Math.max(0, 1 - share.systemShare) : null;
523
+ logger.info(
524
+ `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
525
+ `system=${asPercent(share.systemShare)} ` +
526
+ `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
527
+ `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
528
+ `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
529
+ ` over=${share.elapsedSec.toFixed(1)}s` +
530
+ // What the offer will multiply a prediction by, in the same line as the
531
+ // readings it comes from.
532
+ ` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}`
533
+ );
403
534
  }
404
535
 
405
536
  /**
@@ -433,7 +564,8 @@ export class HostLoad {
433
564
 
434
565
  /** @returns {Promise<boolean>} True when no encoder is running, which is when the torrent's cost can be taken. */
435
566
  async #reportMachineOnce() {
436
- const encoding = [...this.#host.outputs.values()].filter(
567
+ const outputs = [...this.#host.outputs.values()];
568
+ const encoding = outputs.filter(
437
569
  (session) => processCanBeSignalled(this.#host.runStateOf(session))
438
570
  );
439
571
  const runningNow = encoding.filter((session) => this.#host.runStateOf(session) !== ENCODE_RUN_STATE.SUSPENDED);
@@ -443,7 +575,8 @@ export class HostLoad {
443
575
  // in which every encoder was suspended, the torrent's price could have
444
576
  // been taken, and none was. What this process spends now is the download,
445
577
  // the verification, the piece store and the delivery. Item 7.
446
- await this.#learnIdleAvailability();
578
+ await this.#learnIdleAvailability(outputs.length > 0,
579
+ encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length);
447
580
  this.#hostLoadSample = null;
448
581
  return true;
449
582
  }
@@ -514,24 +647,12 @@ export class HostLoad {
514
647
  // they stopped).
515
648
  const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
516
649
  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
650
  // Kept for the quality offer, which predicts from a benchmark taken on a
520
651
  // QUIET host: the same reading that is printed here says how much of the
521
652
  // machine a new encoder could actually have. Only what nobody has been
522
653
  // charged for is subtracted — see `encode/available-share.js`.
523
654
  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
- );
655
+ await this.#sayMachine(share, proxyShare, running, suspended);
535
656
  return false;
536
657
  }
537
658
 
@@ -541,8 +662,8 @@ export class HostLoad {
541
662
  *
542
663
  * A viewer consumes the file at its own byte rate, and every one of those
543
664
  * 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
665
+ * megabyte by `observedTorrentCostPerMegabyte`, which is unset while no price
666
+ * is established. Zero while either term is unmeasured
546
667
  * — a guess here would refuse rungs on arithmetic nobody performed.
547
668
  *
548
669
  * @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