@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 +1 -1
- package/server.js +1 -0
- package/services/encode/EncodeInputs.js +18 -5
- package/services/encode/EncodeRuns.js +1 -1
- package/services/encode/quality/HostLoad.js +198 -83
- package/services/server/wire-outputs.js +35 -2
- package/services/storage/processor-share.js +57 -0
- package/services/torrent/torrent-pool.js +29 -0
- package/services/torrent/worker/client.js +11 -0
- package/services/torrent/worker/pool-adapter.js +11 -0
- package/services/torrent/worker/protocol.js +6 -0
- package/services/torrent/worker/worker.js +10 -1
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
58
|
-
* torrents moving nothing — the spending that would have happened anyway
|
|
59
|
-
*
|
|
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
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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.#
|
|
162
|
-
// How much the draw's own readings disagree, which is how much of
|
|
163
|
-
// interval's remainder means nothing.
|
|
164
|
-
drawScatter: scatterOf(this.#
|
|
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
|
|
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.#
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
540
|
-
*
|
|
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
|
-
|
|
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.
|