@torrent-tv/proxy 2.101.8 → 2.101.10

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.101.8",
3
+ "version": "2.101.10",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -1068,6 +1068,7 @@ export async function startProxyServer({
1068
1068
  // seconds into bytes, what to ask the swarm for, what to keep in memory —
1069
1069
  // is its own business.
1070
1070
  setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
1071
+ setSpeculativeHold: (held, because) => torrentPool.setSpeculativeHold?.(held, because),
1071
1072
  setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
1072
1073
  void sourcePreparation?.refresh();
1073
1074
  const record = sourceRegistry.get(sourceKey);
@@ -152,6 +152,14 @@ export class EncodeOrchestrator {
152
152
  */
153
153
  #pauseRule = new PauseRule();
154
154
 
155
+ /**
156
+ * Whether the run a viewer needs first is short of the processor, from the
157
+ * last time who has the processor was decided (`processorShortfall`).
158
+ *
159
+ * @type {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null}
160
+ */
161
+ #shortfall = null;
162
+
155
163
  /**
156
164
  * What starting and stopping an encoder was measured to cost on this host,
157
165
  * before any viewer existed. Told once; real runs replace it as they end.
@@ -238,6 +246,8 @@ export class EncodeOrchestrator {
238
246
  * @param {{ freeCores: () => number | null, otherCores: () => number | null } | null} [params.processor] -
239
247
  * The processor left free and the processor other work takes, in cores
240
248
  * (`ProcessorBudget`); absent, both are unknown.
249
+ * @param {() => void} [params.shortfallNoted] - Told each time who has the
250
+ * processor was decided, so the download can read `processorShortfall`.
241
251
  */
242
252
  constructor({
243
253
  maxRunsFor,
@@ -253,6 +263,7 @@ export class EncodeOrchestrator {
253
263
  describeWaits = null,
254
264
  admission = null,
255
265
  processor = null,
266
+ shortfallNoted = null,
256
267
  logger,
257
268
  now
258
269
  }) {
@@ -262,6 +273,7 @@ export class EncodeOrchestrator {
262
273
  // is about one output, and then only the per-output limit binds.
263
274
  this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
264
275
  this.processor = processor && typeof processor.freeCores === "function" ? processor : null;
276
+ this.shortfallNoted = typeof shortfallNoted === "function" ? shortfallNoted : () => undefined;
265
277
  this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
266
278
  // Only the segment facts and operations this planner needs cross the
267
279
  // storage boundary; the concrete storage remains in the server assembly.
@@ -720,12 +732,16 @@ export class EncodeOrchestrator {
720
732
  const leading = this.#leadingBand();
721
733
  this.#leading = leading;
722
734
  if (!leading) {
735
+ this.#noteShortfall(null);
723
736
  this.#placeHeldBack(null);
724
737
  return;
725
738
  }
726
739
  const now = this.now();
727
740
  const running = this.#runningNow();
728
741
  const need = this.#needOf(leading);
742
+ // Only a run in a band a viewer is in can be short for a viewer.
743
+ const short = leading.urgent === true ? this.#pauseRule.shortfall({ leader: leading.run, need }) : null;
744
+ this.#noteShortfall(short ? { address: leading.address, ...short } : null);
729
745
  const free = this.#processorReading("freeCores");
730
746
  const others = this.#processorReading("otherCores");
731
747
  for (const [address, runs] of this.#runs) {
@@ -752,6 +768,25 @@ export class EncodeOrchestrator {
752
768
  this.#placeHeldBack(leading);
753
769
  }
754
770
 
771
+ /**
772
+ * Whether the run a viewer needs first is short of the processor, and by
773
+ * what: its output, the speed it made over its seconds kept (median and
774
+ * scatter) and the speed its deadlines need, infinite where a viewer waits
775
+ * for it (`PauseRule.shortfall`, torrent-tv/meta#180). Null where it is not
776
+ * short, or nothing measured it.
777
+ *
778
+ * @returns {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null}
779
+ */
780
+ processorShortfall() {
781
+ return this.#shortfall;
782
+ }
783
+
784
+ /** @param {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null} short */
785
+ #noteShortfall(short) {
786
+ this.#shortfall = short;
787
+ this.shortfallNoted();
788
+ }
789
+
755
790
  /**
756
791
  * One second of each encoder's processor and speed, by process
757
792
  * (`storage/processor-report.js`): what `PauseRule` judges from. Who has the
@@ -198,6 +198,29 @@ export class PauseRule {
198
198
  return { runs: true, because: measured };
199
199
  }
200
200
 
201
+ /**
202
+ * Whether the leader is short of the processor: its last second made film
203
+ * without waiting for its input, and over the seconds kept it made less than
204
+ * its need — anything at all where a viewer waits for it. Null where it is
205
+ * not short, waits for its input, or has fewer seconds than a median is
206
+ * taken from (torrent-tv/meta#180, phase 5: what the download's speculative
207
+ * levels give way to).
208
+ *
209
+ * @param {object} params
210
+ * @param {object} params.leader
211
+ * @param {number} params.need - `s*`, as for `judge`.
212
+ * @returns {{ speed: { middle: number, scatter: number }, need: number, seconds: number } | null}
213
+ */
214
+ shortfall({ leader, need }) {
215
+ const kept = this.#seconds.get(leader) ?? [];
216
+ if (!(need > 0) || kept.length === 0 || kept.at(-1).waiting) return null;
217
+ const lead = kept.filter((second) => !second.waiting && second.speed !== null);
218
+ if (lead.length < LEAST_SECONDS) return null;
219
+ const speed = middleAndScatter(lead.map((second) => second.speed));
220
+ if (Number.isFinite(need) && speed.middle >= need) return null;
221
+ return { speed, need, seconds: lead.length };
222
+ }
223
+
201
224
  /**
202
225
  * Whether a run not started yet, on `address` and less urgent than
203
226
  * `leader`, would be let go at once: a start the rule would pause is not
@@ -633,8 +633,10 @@ export class Renditions {
633
633
  }
634
634
  const bar = this.#host.requiredSpeedOf?.(base) ?? 1;
635
635
  const priced = [];
636
+ const speeds = new Map();
636
637
  const holds = (preset) => {
637
638
  const speed = this.#host.presetSpeedOf?.(current, preset) ?? null;
639
+ speeds.set(preset, speed);
638
640
  priced.push(`${preset}=${speed ? `${speed.effective.toFixed(2)}x` : "unpriced"}`);
639
641
  return speed !== null && speed.effective >= bar;
640
642
  };
@@ -654,6 +656,7 @@ export class Renditions {
654
656
  return this.#beginMove({
655
657
  base, current, consumerId, askedHeights, direction, changes: "preset",
656
658
  reasonText: `${reasonText}; priced ${priced.join(", ")} against ${bar.toFixed(2)}x`,
659
+ speed: speeds.get(target)?.processing ?? null,
657
660
  opening: this.#stepOpening(base, {
658
661
  height: encode.height,
659
662
  startSeconds: this.#host.viewerPositionOf(base.id, consumerId),
@@ -679,7 +682,8 @@ export class Renditions {
679
682
  * @param {object} move
680
683
  * @returns {Promise<{ started: boolean, reason: string, noPlace?: boolean }>}
681
684
  */
682
- async #beginMove({ base, current, consumerId, askedHeights, direction, changes, reasonText, opening, what, matches, described }) {
685
+ async #beginMove({ base, current, consumerId, askedHeights, direction, changes, reasonText, opening, what, matches, described,
686
+ speed = null }) {
683
687
  const refuse = (reason) => ({ started: false, reason });
684
688
  let prepared;
685
689
  try {
@@ -728,6 +732,11 @@ export class Renditions {
728
732
  prepared.variantHeight ??= current.spec.video.encode.height;
729
733
  this.#host.outputs.markStep(prepared);
730
734
  }
735
+ // The speed this move was decided at is what the start forecast reads for
736
+ // the output until a piece of it is measured (torrent-tv/meta#185).
737
+ if (Number.isFinite(speed) && speed > 0) {
738
+ this.#host.notePredictedSpeed?.(prepared, speed);
739
+ }
731
740
  this.#host.noteSameHeightSwitch(consumerId, {
732
741
  askedHeights,
733
742
  outputId: prepared.id,
@@ -128,6 +128,33 @@ export class EncodeCost {
128
128
  }
129
129
  }
130
130
 
131
+ /**
132
+ * The speed a move onto another setting of this output's encoder was decided
133
+ * at (`presetSpeedOf`, its processing): what this output stands on until a
134
+ * piece of it is measured, instead of the figure taken when it was opened.
135
+ * That figure counts the output the move replaces as running beside it.
136
+ * Field r82 (torrent-tv/meta#185): an output at `faster` opened during a cold
137
+ * start was predicted 0.22x, its move was priced 1.81x, it measured
138
+ * 1.46-1.65x, and the start forecast on 0.22x stated 30 596 s where the
139
+ * picture came in 10.5 s. The price assumes the output on screen stops; where
140
+ * another viewer keeps it running, the figure is high until a piece is
141
+ * measured. A move is not an offer, so no cheapest-mode figure stands beside
142
+ * it.
143
+ *
144
+ * @param {object} session - The output the move is prepared onto.
145
+ * @param {number} speed - Seconds of film per second.
146
+ * @returns {void}
147
+ */
148
+ notePriceFor(session, speed) {
149
+ const preset = session?.spec?.video?.encode?.preset ?? null;
150
+ if (!session?.spec?.transcodesVideo || !preset || !Number.isFinite(speed) || speed <= 0) return;
151
+ const state = qualityStateOf(session);
152
+ state.predictedSpeedWhenOffered = speed;
153
+ state.predictedMode = preset;
154
+ state.offeredSpeedAtCheapestMode = null;
155
+ state.lastPredictionRatio = null;
156
+ }
157
+
131
158
  /**
132
159
  * What this output's picture would do at another speed setting of its
133
160
  * encoder, at the same size, on this machine as it is: the startup
@@ -123,6 +123,15 @@ export class HostLoad {
123
123
  /** @type {number[]} Recent readings behind that median. */
124
124
  #torrentCostReadings = [];
125
125
 
126
+ /**
127
+ * What every torrent carried per interval, in bytes per second: in all, and
128
+ * over the time our selection held no connection back (`SelectionLimit`).
129
+ * Kept as readings for `swarmRate`.
130
+ *
131
+ * @type {{ carried: number[], unheld: number[] }}
132
+ */
133
+ #swarmReadings = { carried: [], unheld: [] };
134
+
126
135
  /**
127
136
  * The share of the machine this process draws with nothing encoding and the
128
137
  * torrents moving nothing — the spending that would have happened anyway.
@@ -264,6 +273,7 @@ export class HostLoad {
264
273
  // worker starts its clock again: such an interval says nothing.
265
274
  if (!(elapsedSec > 0) || megabytes < 0 || now.threadCpuSeconds < previous.threadCpuSeconds) return;
266
275
  const cpuSeconds = (now.threadCpuSeconds - previous.threadCpuSeconds) / Math.max(1, os.cpus().length);
276
+ this.#noteSwarm(now, previous, megabytes * 1e6 / elapsedSec);
267
277
  const limit = this.downloadLimit;
268
278
  // What else the thread moved over the same interval, across every torrent:
269
279
  // sent back to the swarm, and handed to readers (encoder inputs, container
@@ -311,6 +321,39 @@ export class HostLoad {
311
321
  );
312
322
  }
313
323
 
324
+ /**
325
+ * What the swarm can give every torrent, in bytes per second: the median of
326
+ * what it carried while our selection held no connection back, but never
327
+ * less than the median of what it carried in all — the same choice the
328
+ * start forecast makes for one source (`ViewerRequests` `sourceService`).
329
+ * Time our selection held a connection back measures our asking, not the
330
+ * swarm, so holding the speculative levels back does not lower it
331
+ * (torrent-tv/meta#180). Null before any interval was read.
332
+ *
333
+ * @returns {number | null}
334
+ */
335
+ swarmRate() {
336
+ const carried = medianOf(this.#swarmReadings.carried);
337
+ const unheld = medianOf(this.#swarmReadings.unheld);
338
+ if (carried === null && unheld === null) return null;
339
+ return Math.max(carried ?? 0, unheld ?? 0);
340
+ }
341
+
342
+ /**
343
+ * @param {{ selection: { freeBytes?: number, freeMs?: number } | null }} now
344
+ * @param {{ selection: { freeBytes?: number, freeMs?: number } | null }} previous
345
+ * @param {number} carried - Bytes per second over the interval, every torrent.
346
+ */
347
+ #noteSwarm(now, previous, carried) {
348
+ this.#swarmReadings.carried = [...this.#swarmReadings.carried, carried].slice(-READINGS_KEPT);
349
+ const freeMs = (now.selection?.freeMs ?? 0) - (previous.selection?.freeMs ?? 0);
350
+ const freeBytes = (now.selection?.freeBytes ?? 0) - (previous.selection?.freeBytes ?? 0);
351
+ // Totals that went back belong to a torrent that left: nothing to read.
352
+ if (freeMs > 0 && freeBytes >= 0) {
353
+ this.#swarmReadings.unheld = [...this.#swarmReadings.unheld, freeBytes * 1000 / freeMs].slice(-READINGS_KEPT);
354
+ }
355
+ }
356
+
314
357
  /**
315
358
  * What the torrent thread draws in an interval that downloaded nothing.
316
359
  *
@@ -205,6 +205,7 @@ export function wireOutputs({
205
205
  getSourceStats = null,
206
206
  setPriorityMap = null,
207
207
  setDownloadRate = null,
208
+ setSpeculativeHold = null,
208
209
  // What a second job costs on this host, measured at startup. Null when it
209
210
  // could not be measured, and then nothing is corrected — the alternative
210
211
  // is inventing a penalty, which is the same fault as inventing a fill rate.
@@ -456,6 +457,14 @@ export function wireOutputs({
456
457
  presetLadder: () => (parts.encoders.current?.kind === "software" && Array.isArray(parts.softwarePresetBenchmark)
457
458
  ? parts.softwarePresetBenchmark.map((entry) => entry.preset) : []),
458
459
  presetSpeedOf: (output, preset) => parts.encodeCost.presetSpeedOf(output, preset, { segmentSeconds: parts.segmentDurationSec }),
460
+ // The price a move was decided at, given to an output with no measured
461
+ // piece: one already measured stands on its readings, and an output that
462
+ // runs was priced with its own cost counted beside the one on screen.
463
+ notePredictedSpeed: (output, speed) => {
464
+ if (parts.encodeRuns.speedReadingsOf(output).length === 0 && parts.encodeRuns.liveRunsOf(output).length === 0) {
465
+ parts.encodeCost.notePriceFor(output, speed);
466
+ }
467
+ },
459
468
  requiredSpeedOf: (output) => parts.hostLoad.requiredSpeedFor(output.file.sourceKey, output.file.fileIndex) ?? 1,
460
469
  heightsChosenAs: (consumerId, outputKey) => heightsChosenAs(parts.viewers, consumerId, outputKey),
461
470
  highestGivenSegmentOf: (consumerId, askedHeight) => highestGivenSegmentOf(parts.viewers, consumerId, askedHeight),
@@ -745,6 +754,48 @@ export function wireOutputs({
745
754
  void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
746
755
  `over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
747
756
  };
757
+ parts.setSpeculativeHold = typeof setSpeculativeHold === "function" ? setSpeculativeHold : null;
758
+ // THE DOWNLOAD GIVES WAY TO THE ENCODER A VIEWER NEEDS FIRST
759
+ // (torrent-tv/meta#180, phase 5). While that encoder makes film on the
760
+ // processor and less than its deadlines need, what nobody needs yet — the
761
+ // film's tail, the gap behind a seek — is not downloaded, so the torrent
762
+ // thread's processor goes to it. Only where the swarm gives more than the
763
+ // film needs at the speed asked of it (its need, or with a viewer waiting
764
+ // what it makes up to its scatter): then every byte an encoder will reach
765
+ // still comes first and in time, and nothing is lost by not fetching ahead.
766
+ // Where the swarm is slower than that, the bytes fetched ahead are what the
767
+ // film will play from, and they are fetched. Decided each time who has the
768
+ // processor is decided and each time the machine is read.
769
+ parts.speculativeHeld = false;
770
+ parts.applySpeculativeHold = () => {
771
+ if (!parts.setSpeculativeHold || !parts.hostLoad || !parts.encodeOrchestrator) return;
772
+ const short = parts.encodeOrchestrator.processorShortfall();
773
+ let held = false;
774
+ let because = "the encoder a viewer needs first is not short of the processor";
775
+ if (short) {
776
+ const output = parts.outputs.outputsOn(short.address)[0] ?? null;
777
+ const length = Number(output?.file?.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(output?.file?.key));
778
+ const bytesPerFilmSecond = length / Number(output?.file?.durationSeconds);
779
+ const asked = Number.isFinite(short.need) ? short.need : short.speed.middle + short.speed.scatter;
780
+ const needs = bytesPerFilmSecond * asked;
781
+ const swarm = parts.hostLoad.swarmRate();
782
+ const made = `the encoder a viewer needs first made ${short.speed.middle.toFixed(2)}x ` +
783
+ `(scatter ${short.speed.scatter.toFixed(2)}) over ${short.seconds}s` +
784
+ (Number.isFinite(short.need) ? `, needing ${short.need.toFixed(2)}x` : " with a viewer waiting");
785
+ if (!Number.isFinite(needs) || needs <= 0) {
786
+ because = `${made}; its film's bytes per second are unknown`;
787
+ } else if (swarm === null) {
788
+ because = `${made}; the swarm's rate is not measured yet`;
789
+ } else {
790
+ held = swarm > needs;
791
+ because = `${made}; the swarm gives ${(swarm / 1e6).toFixed(2)} MB/s, ` +
792
+ `${held ? "more than" : "not more than"} the ${(needs / 1e6).toFixed(2)} MB/s its film needs at ${asked.toFixed(2)}x`;
793
+ }
794
+ }
795
+ if (held === parts.speculativeHeld) return;
796
+ parts.speculativeHeld = held;
797
+ void Promise.resolve(parts.setSpeculativeHold(held, because)).catch(() => {});
798
+ };
748
799
  // The one owner of what the processor gives the encoders: the share of the
749
800
  // machine other work leaves and the measured contention penalties, read by
750
801
  // every place that decides on the processor (torrent-tv/meta#180).
@@ -1082,6 +1133,7 @@ export function wireOutputs({
1082
1133
  // The processor left free and what other work takes, for the pause rule
1083
1134
  // (torrent-tv/meta#180, phase 4).
1084
1135
  processor: parts.processor,
1136
+ shortfallNoted: () => parts.applySpeculativeHold?.(),
1085
1137
  startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
1086
1138
  segmentCoverage,
1087
1139
  // HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
@@ -1249,7 +1301,11 @@ export function wireOutputs({
1249
1301
  liveConsumers: (...args) => parts.renditions.liveConsumers(...args),
1250
1302
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
1251
1303
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
1252
- reportHostLoad: async (...args) => { await parts.hostLoad.reportHostLoad(...args); parts.applyDownloadShare?.(); },
1304
+ reportHostLoad: async (...args) => {
1305
+ await parts.hostLoad.reportHostLoad(...args);
1306
+ parts.applyDownloadShare?.();
1307
+ parts.applySpeculativeHold?.();
1308
+ },
1253
1309
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
1254
1310
  sampleDownloadRates: (...args) => parts.hostLoad.sampleDownloadRates(...args),
1255
1311
  get encodeCost() { return parts.encodeCost; },
@@ -30,6 +30,12 @@ let futurePending = null;
30
30
  let publishingSelections = false;
31
31
  /** What the last reconciliation told the swarm to fetch first, for the pool to say. */
32
32
  let firstClassStated = "none";
33
+ /**
34
+ * Whether the speculative levels are held back for the processor: the encoder
35
+ * a viewer needs first is short of it, and the swarm gives more than its film
36
+ * needs (`holdSpeculative`, torrent-tv/meta#180).
37
+ */
38
+ let speculativeHeld = false;
33
39
 
34
40
  /**
35
41
  * The last forecast that finished on the maps it was begun on: its result, the
@@ -222,6 +228,26 @@ export function firstClassStatement() {
222
228
  return firstClassStated;
223
229
  }
224
230
 
231
+ /**
232
+ * Hold the speculative levels back, or let them go again, on every torrent.
233
+ *
234
+ * Downloading what nobody needs yet takes the torrent thread's processor
235
+ * (Home Assistant r84: 9-19 % of the machine while it downloaded 7-14 MB/s
236
+ * and handed readers about 0.5 MB/s in most intervals, with 0-6 % of the
237
+ * machine free). The side that measures the encoders decides when that processor is
238
+ * worth more to them (torrent-tv/meta#180, phase 5); the urgent levels are
239
+ * never held, so every byte an encoder will reach is still asked for first.
240
+ *
241
+ * @param {boolean} held
242
+ * @returns {boolean} Whether this changed anything.
243
+ */
244
+ export function holdSpeculative(held) {
245
+ const next = held === true;
246
+ if (next === speculativeHeld) return false;
247
+ speculativeHeld = next;
248
+ return true;
249
+ }
250
+
225
251
  /**
226
252
  * Bring every torrent's download set into line with what is stated.
227
253
  *
@@ -235,7 +261,7 @@ export function firstClassStatement() {
235
261
  export function reconcileAll() {
236
262
  if (publishingSelections) return { torrents: live.size, speculativeAllowed: false, stated: 0, withdrawn: 0 };
237
263
  const entries = [...live];
238
- const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
264
+ const speculativeAllowed = !speculativeHeld && !entries.some((entry) => entry.selection.hasUrgentMissing());
239
265
  // The first class across every torrent: the most urgent missing level and
240
266
  // its highest priority. Lower classes stay out of every torrent's selection.
241
267
  const bands = entries.map(entry => entry.selection.missingBand()).filter(Boolean);
@@ -18,7 +18,7 @@ import { logger } from "../../utils/logger.js";
18
18
  import { pieceStoreOf } from "./piece-store-of.js";
19
19
  import { demandProgress } from "./demand-progress.js";
20
20
  import { Urgency, urgencyName } from "./demand/index.js";
21
- import { demandFor, firstClassStatement, forgetTorrent, reconcileAll, hasUnmetDemand, selectionLimitOf } from "./download/registry.js";
21
+ import { demandFor, firstClassStatement, forgetTorrent, holdSpeculative, reconcileAll, hasUnmetDemand, selectionLimitOf } from "./download/registry.js";
22
22
  import { applyDownloadRate } from "./download/throttle.js";
23
23
  import { withdrawClaim } from "./download/withdraw-claim.js";
24
24
  import { deadlineOfMapZone, levelOfMapZone, priorityOfMapZone } from "./map-zones.js";
@@ -1309,6 +1309,20 @@ export class TorrentPool {
1309
1309
  /** The power of two the last limit said was nearest, null for none. */
1310
1310
  #downloadLimitScale = null;
1311
1311
 
1312
+ /**
1313
+ * Hold back, or let go, what every torrent downloads speculatively: the
1314
+ * film's tail and the gap behind a seek (`download/registry.js`
1315
+ * `holdSpeculative`, torrent-tv/meta#180). Said when it changes.
1316
+ *
1317
+ * @param {boolean} held
1318
+ * @param {string} [because]
1319
+ */
1320
+ setSpeculativeHold(held, because = "") {
1321
+ if (!holdSpeculative(held)) return;
1322
+ logger.info(`torrent-pool: speculative download ${held === true ? "held" : "let go"}${because ? ` (${because})` : ""}`);
1323
+ reconcileAll();
1324
+ }
1325
+
1312
1326
  applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
1313
1327
  // A MAP WITH NOTHING IN IT IS A STATEMENT, and it is answered before
1314
1328
  // anything else is looked at. It says nobody wants any of this file, and
@@ -511,6 +511,17 @@ export class TorrentWorkerClient {
511
511
  return this.#caller.call(Command.DOWNLOAD_RATE, { bytesPerSecond: Number.isFinite(bytesPerSecond) ? bytesPerSecond : null, because });
512
512
  }
513
513
 
514
+ /**
515
+ * Whether the speculative levels are held back (`Command.SPECULATIVE_HOLD`).
516
+ *
517
+ * @param {boolean} held
518
+ * @param {string} [because]
519
+ * @returns {Promise<void>}
520
+ */
521
+ async setSpeculativeHold(held, because = "") {
522
+ return this.#caller.call(Command.SPECULATIVE_HOLD, { held: held === true, because });
523
+ }
524
+
514
525
  async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
515
526
  return this.#caller.call(Command.PRIORITY_MAP, {
516
527
  sourceKey,
@@ -179,6 +179,17 @@ export class WorkerTorrentPool {
179
179
  return this.#client.setDownloadRate(bytesPerSecond, because);
180
180
  }
181
181
 
182
+ /**
183
+ * Whether the speculative levels are held back for the encoders.
184
+ *
185
+ * @param {boolean} held
186
+ * @param {string} [because]
187
+ * @returns {Promise<void>}
188
+ */
189
+ async setSpeculativeHold(held, because = "") {
190
+ return this.#client.setSpeculativeHold(held, because);
191
+ }
192
+
182
193
  async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
183
194
  if (!sourceKey) {
184
195
  return;
@@ -72,6 +72,12 @@ export const Command = {
72
72
  * (`storage/processor-share.js`, torrent-tv/meta#178).
73
73
  */
74
74
  DOWNLOAD_RATE: "download-rate",
75
+ /**
76
+ * Whether the speculative levels are held back for the processor the
77
+ * encoders need (`download/registry.js` `holdSpeculative`,
78
+ * torrent-tv/meta#180).
79
+ */
80
+ SPECULATIVE_HOLD: "speculative-hold",
75
81
  /** Read a byte range; the body arrives as CHUNK messages. */
76
82
  READ_RANGE: "read-range",
77
83
  /** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
@@ -525,6 +525,11 @@ async function runCommand(command, params, id) {
525
525
  return true;
526
526
  }
527
527
 
528
+ case Command.SPECULATIVE_HOLD: {
529
+ pool.setSpeculativeHold(params.held === true, params.because);
530
+ return true;
531
+ }
532
+
528
533
  case Command.READ_RANGE: {
529
534
  // Streams its own reply; the caller's promise resolves once the body has
530
535
  // been fully sent, which is what lets the client await completion.