@torrent-tv/proxy 2.66.0 → 2.67.0

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.66.0",
3
+ "version": "2.67.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -117,6 +117,19 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
117
117
  };
118
118
  for (const file of matched.audio) {
119
119
  warmOne(file, { tailBytes: 0 });
120
+ // And then the whole of it, in the room the viewer's own reading leaves.
121
+ // The head is enough to NAME the track; it is not enough to play one, and
122
+ // a viewer who switches otherwise waits for the swarm to deliver its
123
+ // first pieces — 27.7 s in the field on 2026-08-31, longer than the
124
+ // switch is willing to wait. A soundtrack is about a twentieth of the
125
+ // picture, and the fill stands aside for every moment the picture's own
126
+ // reader is blocked, so it uses capacity the viewer is not using.
127
+ if (typeof torrentPool.fillFileInBackground === "function") {
128
+ Promise.resolve(torrentPool.fillFileInBackground(torrent, file.fileIndex)).catch((error) => {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: filling "${file.name}" failed: ${message}`);
131
+ });
132
+ }
120
133
  }
121
134
  for (const file of matched.subtitles) {
122
135
  warmOne(
package/server.js CHANGED
@@ -206,15 +206,13 @@ export async function startProxyServer({
206
206
  return;
207
207
  }
208
208
  const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
209
- const file = torrent?.files?.[fileIndex];
210
- if (!file || !Number.isFinite(file.length) || file.length <= 0) {
211
- return;
212
- }
213
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
214
- headBytes: file.length,
215
- tailBytes: 0,
216
- timeoutMs: 600_000
217
- });
209
+ // The same background fill the warm-up starts when a file is chosen, not a
210
+ // second way of doing it. It is guarded against running twice on one file,
211
+ // so the two triggers converge instead of putting two readers on the same
212
+ // soundtrack — and only one of them would have stood aside for the
213
+ // picture. This trigger remains for the session that never had a warm-up
214
+ // before it.
215
+ await torrentPool.fillFileInBackground?.(torrent, fileIndex);
218
216
  }
219
217
  });
220
218
  const playbackPlanner = createPlaybackPlanner({
@@ -4440,6 +4440,27 @@ export class HlsSessionManager {
4440
4440
  const next = this.#splicableHeights(base)
4441
4441
  .find((height) => height < current && offered.includes(height));
4442
4442
  if (next === undefined) {
4443
+ // Nothing lower — but "lower" is not the same question as "cheaper", and
4444
+ // on a source that is COPIED the answer is above, not below. A copied
4445
+ // rung costs no encoder at all, whatever its size, so when a re-encode
4446
+ // cannot keep up it is both the fastest thing this host can serve AND the
4447
+ // best picture it has.
4448
+ //
4449
+ // Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
4450
+ // 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
4451
+ // own 1038p sat on offer beside it, copied and free. This line printed
4452
+ // fifty times — "nothing lower is on offer; leaving the picture alone" —
4453
+ // and the picture stood still 161 times for 940 seconds. The rescue was
4454
+ // on the screen the whole time and the rule could only look down.
4455
+ const copied = this.#copiedHeightOf(base);
4456
+ if (copied > 0 && copied !== current && offered.includes(copied)) {
4457
+ logger.info(
4458
+ `[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
4459
+ `but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
4460
+ `Asking for it instead of leaving the viewer on an encode that cannot keep up`
4461
+ );
4462
+ return this.#askQualityHeight(base, copied, reasonText);
4463
+ }
4443
4464
  logger.info(
4444
4465
  `[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
4445
4466
  `for "${session.fileName}"; leaving the picture alone`
@@ -4449,6 +4470,25 @@ export class HlsSessionManager {
4449
4470
  return this.#askQualityHeight(base, next, reasonText);
4450
4471
  }
4451
4472
 
4473
+ /**
4474
+ * The height this family serves by COPY, or zero when every rung is encoded.
4475
+ *
4476
+ * The one rung whose cost does not depend on the machine: the source's own
4477
+ * height, on a base whose video is not re-encoded. `offeredHeights` never
4478
+ * withdraws it for that reason, so it is always available as somewhere to
4479
+ * return to — which is exactly what {@link HlsSessionManager##askLowerHeight}
4480
+ * had no way to say.
4481
+ *
4482
+ * @param {HlsSession} base
4483
+ * @returns {number}
4484
+ */
4485
+ #copiedHeightOf(base) {
4486
+ if (!base || base.transcodeVideo === true) {
4487
+ return 0;
4488
+ }
4489
+ return Math.round(Number(base.sourceHeight) || 0);
4490
+ }
4491
+
4452
4492
  /**
4453
4493
  * Record a request to the viewer's player to move to another variant.
4454
4494
  *
@@ -9,8 +9,22 @@
9
9
  *
10
10
  * Both follow from the same two measured quantities, and from nothing else:
11
11
  *
12
- * W — how long a read waits for a piece it needs (the worst recent one);
13
- * T — how long there is between such waits (the median recent interval).
12
+ * W — how long the supply is INTERRUPTED (the worst recent stall);
13
+ * T — how long the encoder RUNS between two stalls (the median recent gap).
14
+ *
15
+ * Both words are load-bearing, and getting either wrong is what produced a
16
+ * demanded speed of 4422x on 2026-08-31.
17
+ *
18
+ * A stall is not a wait. Several readers walk one file — the picture and each
19
+ * audio rendition — so a piece that has not arrived blocks all of them, and
20
+ * their waits end within milliseconds of each other. Counted as separate
21
+ * interruptions they gave an interval of 0.00 s. Waits are therefore merged into
22
+ * the stretches during which the supply was not delivering, however many readers
23
+ * noticed.
24
+ *
25
+ * And T is the RUNNING time, from the end of one stall to the start of the next
26
+ * — not the spacing between their ends, which includes a stall's own duration
27
+ * and so credits the encoder with cushion it was not building.
14
28
  *
15
29
  * **The margin.** A step producing at speed `v` gains `v - 1` seconds of
16
30
  * cushion for every second it runs, and an interruption of `W` seconds costs
@@ -19,10 +33,13 @@
19
33
  *
20
34
  * (v - 1) × T > W i.e. v > 1 + W / T
21
35
  *
22
- * On the field torrent: waits every 2.22 s, worst 3.16 s, so the honest bar is
23
- * 2.42 against the 1.5 that was assumed, and the 1.05 that was measured. The
24
- * same arithmetic explains why a copied stream never stalls: at 8x it gains
25
- * 15.5 s between interruptions and loses at most 4.8 s.
36
+ * On the field torrent: stalls of 1.49 s with 0.73 s of running between them,
37
+ * the worst 3.16 s, so the honest bar is 5.33 against the 1.5 that was
38
+ * assumed, and the 1.05 that was measured. (An earlier reading of the same data
39
+ * gave 2.42 by using the end-to-end spacing; it was too low in the same
40
+ * direction as the guess it replaced.) The same arithmetic explains why a copied
41
+ * stream never stalls: at 8x it gains 10.7 s between stalls and loses at most
42
+ * 4.8 s.
26
43
  *
27
44
  * **The buffer.** It must cover the worst interruption that can arrive before
28
45
  * it can be refilled, whichever source that interruption comes from, plus the
@@ -62,10 +79,26 @@ export function requiredSpeedFrom(waits) {
62
79
  if (ordered.length < 2) {
63
80
  return null;
64
81
  }
65
- const worstWaitSec = Math.max(...ordered.map((wait) => wait.waitedMs)) / 1000;
82
+ // One INTERRUPTION, not one wait. Several readers walk the same file — the
83
+ // picture and each audio rendition — and a piece that has not arrived blocks
84
+ // all of them at once. Counted as separate interruptions, those simultaneous
85
+ // waits gave a near-zero interval and therefore a required speed of thousands:
86
+ // measured 2026-08-31, `worst wait 13.26s, one every 0.00s, 2 measured` became
87
+ // 4422.00x, and every quality step was refused against it.
88
+ const interruptions = mergeOverlapping(ordered);
89
+ if (interruptions.length < 2) {
90
+ // One interruption shows no interval, and an interval invented from one
91
+ // point is exactly what this file exists to remove.
92
+ return null;
93
+ }
94
+ const worstWaitSec = Math.max(...interruptions.map((one) => one.end - one.start)) / 1000;
66
95
  const intervals = [];
67
- for (let index = 1; index < ordered.length; index += 1) {
68
- const gapMs = ordered[index].at - ordered[index - 1].at;
96
+ for (let index = 1; index < interruptions.length; index += 1) {
97
+ // From the END of one interruption to the START of the next: that is the
98
+ // stretch the encoder actually runs for and builds cushion in. Measuring
99
+ // end-to-end instead counted each interruption's own duration as part of
100
+ // the recovery it is supposed to be recovered from.
101
+ const gapMs = interruptions[index].start - interruptions[index - 1].end;
69
102
  if (gapMs > 0) {
70
103
  intervals.push(gapMs / 1000);
71
104
  }
@@ -81,10 +114,41 @@ export function requiredSpeedFrom(waits) {
81
114
  requiredSpeed: 1 + worstWaitSec / medianIntervalSec,
82
115
  worstWaitSec,
83
116
  medianIntervalSec,
84
- samples: ordered.length
117
+ // Interruptions, not waits: what the figure is derived from. The two differ
118
+ // whenever more than one reader walks the file, and reporting the raw count
119
+ // is what made the 4422x line look better evidenced than it was — "24
120
+ // measured" was 24 waits over far fewer actual stalls.
121
+ samples: interruptions.length,
122
+ waits: ordered.length
85
123
  };
86
124
  }
87
125
 
126
+ /**
127
+ * Waits joined into the interruptions they actually were.
128
+ *
129
+ * A wait spans `[at - waitedMs, at]`. Two that overlap or touch are one stretch
130
+ * during which the supply was not delivering, however many readers noticed it.
131
+ *
132
+ * @param {SupplyWait[]} ordered - Usable waits, oldest END first.
133
+ * @returns {Array<{ start: number, end: number }>} Disjoint, in time order.
134
+ */
135
+ function mergeOverlapping(ordered) {
136
+ const spans = ordered
137
+ .map((wait) => ({ start: wait.at - wait.waitedMs, end: wait.at }))
138
+ .sort((left, right) => left.start - right.start);
139
+ /** @type {Array<{ start: number, end: number }>} */
140
+ const merged = [];
141
+ for (const span of spans) {
142
+ const last = merged[merged.length - 1];
143
+ if (last && span.start <= last.end) {
144
+ last.end = Math.max(last.end, span.end);
145
+ continue;
146
+ }
147
+ merged.push({ ...span });
148
+ }
149
+ return merged;
150
+ }
151
+
88
152
  /**
89
153
  * The smallest buffer at which no interruption reaches the viewer.
90
154
  *
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Fetch a whole file that nobody is playing yet, using only the room the viewer
3
+ * is not using.
4
+ *
5
+ * WHAT IT IS FOR. A release ships its dub and its subtitles as separate files,
6
+ * and a viewer who switches to one waits for the swarm to deliver its first
7
+ * pieces — 27.7 s in the field on 2026-08-31, which is longer than the switch is
8
+ * willing to wait. These files are small beside the picture (30 MB against 566
9
+ * MB, about a twentieth), so having them on disk before anyone asks turns every
10
+ * later switch into a local read.
11
+ *
12
+ * THE ORDERING, WHICH IS THE WHOLE DESIGN. What plays now comes first: the
13
+ * picture at the playhead, the soundtrack being heard, the subtitles being
14
+ * shown. The other soundtracks and subtitle files come next. Reading the film
15
+ * far ahead comes last. This module implements the middle tier, and it stays
16
+ * below the first by a condition that is measured rather than chosen: it fetches
17
+ * only while NO reader on the torrent is inside a wait. A reader blocked on a
18
+ * piece is the viewer's own reading starving, and that is exactly the moment
19
+ * this must not be asking the swarm for anything.
20
+ *
21
+ * WHY IT IS A READ AND NOT A SELECTION. `file.select()` claims every piece of a
22
+ * file at once. `#syncSelections` in `torrent-pool.js` records what that cost
23
+ * when it was done alongside the readers' own moving windows: a claim covering
24
+ * everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film
25
+ * waited 93 s while the swarm fetched 2.47 GB in file order. So this walks the
26
+ * file a piece at a time through an ordinary bounded read, which claims what it
27
+ * is reading and gives it back.
28
+ */
29
+
30
+ import { logger } from "../../utils/logger.js";
31
+ import { readersAreBlockedOn } from "./piece-reader.js";
32
+
33
+ /**
34
+ * How long to stand aside after finding the viewer's own reading blocked.
35
+ *
36
+ * Not a measurement of anything: it is how often the question "is the viewer
37
+ * still starving?" is asked, and it is answered by the reader count, which is
38
+ * exact. Short enough that room is used soon after it appears, long enough that
39
+ * asking costs nothing.
40
+ */
41
+ const STAND_ASIDE_MS = 1_000;
42
+
43
+ /** Files being filled, by `sourceKey:fileIndex`, so one runs per file. */
44
+ const running = new Map();
45
+
46
+ /**
47
+ * @param {number} ms
48
+ * @returns {Promise<void>}
49
+ */
50
+ function pause(ms) {
51
+ return new Promise((resolve) => {
52
+ const timer = setTimeout(resolve, ms);
53
+ timer.unref?.();
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Read one byte range, letting the torrent fetch what is missing.
59
+ *
60
+ * @param {object} file
61
+ * @param {number} start
62
+ * @param {number} end - Inclusive.
63
+ * @returns {Promise<number>} Bytes read; 0 on failure.
64
+ */
65
+ function readRange(file, start, end) {
66
+ return new Promise((resolve) => {
67
+ let stream;
68
+ try {
69
+ stream = file.createReadStream({ start, end });
70
+ } catch {
71
+ resolve(0);
72
+ return;
73
+ }
74
+ let bytes = 0;
75
+ let settled = false;
76
+ const settle = (value) => {
77
+ if (settled) {
78
+ return;
79
+ }
80
+ settled = true;
81
+ resolve(value);
82
+ };
83
+ stream.on("data", (chunk) => {
84
+ bytes += chunk.length;
85
+ });
86
+ stream.on("end", () => settle(bytes));
87
+ stream.on("error", () => {
88
+ stream.destroy?.();
89
+ settle(0);
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Fetch a file whole, in the room the viewer leaves.
96
+ *
97
+ * Returns as soon as the work is under way; the caller is not waiting for it.
98
+ * One fill per file — a second request while one is running is ignored rather
99
+ * than doubling the reads.
100
+ *
101
+ * @param {object} torrent
102
+ * @param {number} fileIndex
103
+ * @param {string} sourceKey
104
+ * @param {{ chunkBytes?: number, isBlocked?: (infoHash: string) => boolean }} [options]
105
+ * `isBlocked` answers "is the viewer's own reading starving right now"; it is
106
+ * the tier boundary, and it is a parameter so it can be exercised without a
107
+ * swarm.
108
+ * @returns {boolean} Whether a fill was started by this call.
109
+ */
110
+ export function fillFileInBackground(torrent, fileIndex, sourceKey, options = {}) {
111
+ const key = `${sourceKey}:${fileIndex}`;
112
+ if (running.has(key)) {
113
+ return false;
114
+ }
115
+ const file = torrent?.files?.[fileIndex];
116
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
117
+ return false;
118
+ }
119
+ // One piece at a time: the smallest unit the swarm actually delivers, so the
120
+ // gap between two checks of "is the viewer starving?" is as short as it can
121
+ // usefully be.
122
+ const chunkBytes = Number.isFinite(options.chunkBytes) && options.chunkBytes > 0
123
+ ? options.chunkBytes
124
+ : (Number(torrent?.pieceLength) || 4 * 1024 * 1024);
125
+ const isBlocked = typeof options.isBlocked === "function" ? options.isBlocked : readersAreBlockedOn;
126
+ const work = fill(torrent, file, fileIndex, chunkBytes, isBlocked).finally(() => {
127
+ running.delete(key);
128
+ });
129
+ running.set(key, work);
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * @param {object} torrent
135
+ * @param {object} file
136
+ * @param {number} fileIndex
137
+ * @param {number} chunkBytes
138
+ * @param {(infoHash: string) => boolean} isBlocked
139
+ * @returns {Promise<void>}
140
+ */
141
+ async function fill(torrent, file, fileIndex, chunkBytes, isBlocked) {
142
+ const startedAt = Date.now();
143
+ const infoHash = String(torrent?.infoHash ?? "");
144
+ let read = 0;
145
+ let stoodAsideMs = 0;
146
+ logger.info(
147
+ `background-fill: "${String(file.name).slice(0, 40)}" (${(file.length / 1e6).toFixed(1)}MB) will be ` +
148
+ "fetched whole while the viewer's own reading leaves room"
149
+ );
150
+ for (let start = 0; start < file.length; start += chunkBytes) {
151
+ // Stand aside for as long as anything the viewer is watching is waiting on
152
+ // the swarm. This is the tier boundary, and it is checked before every
153
+ // chunk rather than once at the beginning: a torrent that was healthy a
154
+ // moment ago is not evidence about the next second.
155
+ while (isBlocked(infoHash)) {
156
+ stoodAsideMs += STAND_ASIDE_MS;
157
+ await pause(STAND_ASIDE_MS);
158
+ }
159
+ // The torrent may have been destroyed under us — a viewer who left, the
160
+ // disk sweep, a restart. Reading a destroyed file throws, and there is
161
+ // nothing here worth an error.
162
+ if (!torrent?.files?.[fileIndex]) {
163
+ return;
164
+ }
165
+ const bytes = await readRange(file, start, Math.min(start + chunkBytes, file.length) - 1);
166
+ if (bytes === 0) {
167
+ logger.info(
168
+ `background-fill: "${String(file.name).slice(0, 40)}" stopped at ` +
169
+ `${(start / 1e6).toFixed(1)}MB — the read returned nothing`
170
+ );
171
+ return;
172
+ }
173
+ read += bytes;
174
+ }
175
+ logger.info(
176
+ `background-fill: "${String(file.name).slice(0, 40)}" is on disk — ${(read / 1e6).toFixed(1)}MB in ` +
177
+ `${((Date.now() - startedAt) / 1000).toFixed(0)}s, of which ${(stoodAsideMs / 1000).toFixed(0)}s ` +
178
+ "was spent standing aside for the viewer; a switch to it will not wait for the swarm"
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Whether a file is being filled right now. For tests and for the log.
184
+ *
185
+ * @param {string} sourceKey
186
+ * @param {number} fileIndex
187
+ * @returns {boolean}
188
+ */
189
+ export function fillIsRunning(sourceKey, fileIndex) {
190
+ return running.has(`${sourceKey}:${fileIndex}`);
191
+ }
@@ -321,6 +321,16 @@ export class TorrentWorkerClient {
321
321
  * @param {{ sourceKey: string, fileIndex: number }} params
322
322
  * @returns {Promise<{ tracks: object[] }>}
323
323
  */
324
+ /**
325
+ * Fetch one whole file in the room the viewer's own reading leaves.
326
+ *
327
+ * @param {{ sourceKey: string, fileIndex: number }} params
328
+ * @returns {Promise<{ started: boolean }>}
329
+ */
330
+ async fillFile({ sourceKey, fileIndex }) {
331
+ return this.#caller.call(Command.FILL_FILE, { sourceKey, fileIndex });
332
+ }
333
+
324
334
  async getSubtitleTracks({ sourceKey, fileIndex }) {
325
335
  return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
326
336
  }
@@ -600,6 +600,50 @@ function describeSteering(key) {
600
600
  );
601
601
  }
602
602
 
603
+ /**
604
+ * How many readers are blocked on a torrent AT THIS MOMENT, by infohash.
605
+ *
606
+ * Not a history and not an average: the question it answers is "is anything the
607
+ * viewer is watching waiting for the swarm right now", and the only honest
608
+ * answer is a count of readers currently inside a wait.
609
+ *
610
+ * It exists so that work which is NOT what the viewer is watching — fetching a
611
+ * soundtrack or a subtitle file they may switch to later — can proceed while the
612
+ * swarm has room and stand aside the instant it does not. That ordering is the
613
+ * whole of the requirement: the picture and the track being played come first,
614
+ * the other tracks next, and reading the film far ahead last.
615
+ *
616
+ * @type {Map<string, number>}
617
+ */
618
+ const blockedReaders = new Map();
619
+
620
+ /**
621
+ * Whether any reader on this torrent is waiting for a piece right now.
622
+ *
623
+ * @param {string} infoHash
624
+ * @returns {boolean}
625
+ */
626
+ export function readersAreBlockedOn(infoHash) {
627
+ return (blockedReaders.get(infoHash) ?? 0) > 0;
628
+ }
629
+
630
+ /**
631
+ * @param {string} infoHash
632
+ * @param {number} delta
633
+ * @returns {void}
634
+ */
635
+ function countBlockedReader(infoHash, delta) {
636
+ if (!infoHash) {
637
+ return;
638
+ }
639
+ const next = (blockedReaders.get(infoHash) ?? 0) + delta;
640
+ if (next > 0) {
641
+ blockedReaders.set(infoHash, next);
642
+ return;
643
+ }
644
+ blockedReaders.delete(infoHash);
645
+ }
646
+
603
647
  function noteSupplyWait(key, label, waitedMs) {
604
648
  const history = supplyWaits.get(key) ?? [];
605
649
  history.push({ waitedMs, at: Date.now() });
@@ -623,8 +667,14 @@ function noteSupplyWait(key, label, waitedMs) {
623
667
  });
624
668
  logger.info(
625
669
  `supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
626
- `to survive this swarm (worst wait ${demand.worstWaitSec.toFixed(2)}s, one every ` +
627
- `${demand.medianIntervalSec.toFixed(2)}s, ${demand.samples} measured)` +
670
+ // "Interruption", not "wait": several readers walk one file the picture
671
+ // and each audio rendition so one missing piece produces one stall and
672
+ // several waits. Saying how many of each is what makes the figure readable;
673
+ // reporting the waits alone made `2 measured` look like two interruptions
674
+ // 3 ms apart, and the demanded speed came out at 4422x.
675
+ `to survive this swarm (worst stall ${demand.worstWaitSec.toFixed(2)}s, one every ` +
676
+ `${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
677
+ `from ${demand.waits} wait(s)) — ` +
628
678
  `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
629
679
  // What steering the blocked piece onto another peer bought, as the
630
680
  // difference between the waits where it placed something and the waits
@@ -890,6 +940,15 @@ export async function* readFragments({
890
940
  }
891
941
 
892
942
  const waitStartedAt = Date.now();
943
+ // What the WHOLE torrent received while this one piece was missing. It is
944
+ // the reading that separates the two causes a wait can have, and neither
945
+ // could be told from the other before: bytes arriving briskly throughout
946
+ // mean the swarm had capacity and this piece was stuck behind the wire
947
+ // that reserved it — the blocked-piece tail; bytes barely moving mean
948
+ // there was nothing to be had, and no reordering of requests would have
949
+ // helped. Asked of 2026-08-31, when a session with 4-5 peers of 38 known
950
+ // waited 41.32 s at worst and the log could not say which it was.
951
+ const downloadedAtWaitStart = Number(torrent?.downloaded) || 0;
893
952
  // The reader is blocked, so this piece is now the only thing that matters
894
953
  // on this torrent: hand it to the fastest wires that hold it. A block is
895
954
  // reserved for exactly one wire, and the read ends when the slowest
@@ -955,9 +1014,16 @@ export async function* readFragments({
955
1014
  // moment ago may now be the fastest one available.
956
1015
  pushToFastest();
957
1016
  }, 500);
1017
+ // Counted for exactly as long as this reader is inside the wait, so that
1018
+ // background work can stand aside while the viewer's own reading is
1019
+ // starving. The `finally` is what makes it safe: a cancelled or failed
1020
+ // read must not leave the torrent looking permanently blocked, which
1021
+ // would stop that background work for the rest of the session.
1022
+ countBlockedReader(torrent?.infoHash, 1);
958
1023
  try {
959
1024
  await whenPieceReady(torrent, pieceIndex, cancellation);
960
1025
  } finally {
1026
+ countBlockedReader(torrent?.infoHash, -1);
961
1027
  clearInterval(supplyProbe);
962
1028
  }
963
1029
  // What a reader spent waiting for data, attributed to the exact piece. A
@@ -999,11 +1065,21 @@ export async function* readFragments({
999
1065
  }
1000
1066
  if (waitedMs >= PIECE_WAIT_LOG_MS) {
1001
1067
  const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
1068
+ // Two rates, side by side, and no verdict word between them: the swarm's
1069
+ // own delivery during the wait against what this piece managed. Both are
1070
+ // measured; which one a reader calls "the cause" follows from the pair
1071
+ // without a threshold having to be chosen here. Far apart means the
1072
+ // bytes were flowing and this piece was not among them; close together,
1073
+ // or both near zero, means there was nothing to deliver.
1074
+ const swarmBytes = Math.max(0, (Number(torrent?.downloaded) || 0) - downloadedAtWaitStart);
1075
+ const swarmKbps = Math.round(swarmBytes / 1024 / (waitedMs / 1000));
1002
1076
  logger.info(
1003
1077
  `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
1004
1078
  `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
1005
1079
  `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
1006
- `— ${rateKbps}KB/s on this piece; ` +
1080
+ `— ${rateKbps}KB/s on this piece while the swarm delivered ` +
1081
+ `${swarmKbps}KB/s (${(swarmBytes / 1024 / 1024).toFixed(1)}MB) across the torrent, ` +
1082
+ `${Number(torrent?.numPeers) || 0} peers connected; ` +
1007
1083
  (supply
1008
1084
  ? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
1009
1085
  `${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
@@ -134,6 +134,26 @@ export class WorkerTorrentPool {
134
134
  * @param {number} fileIndex
135
135
  * @returns {Promise<object[]>}
136
136
  */
137
+ /**
138
+ * Fetch one whole file using only the room the viewer's own reading leaves.
139
+ *
140
+ * For a soundtrack or subtitle file shipped beside the picture: small next to
141
+ * the film, and having it on disk is what turns a later switch into a local
142
+ * read instead of a wait on the swarm.
143
+ *
144
+ * @param {object} torrent
145
+ * @param {number} fileIndex
146
+ * @returns {Promise<boolean>} Whether a fill was started by this call.
147
+ */
148
+ async fillFileInBackground(torrent, fileIndex) {
149
+ const sourceKey = torrent?.sourceKey;
150
+ if (!sourceKey) {
151
+ return false;
152
+ }
153
+ const answer = await this.#client.fillFile({ sourceKey, fileIndex });
154
+ return answer?.started === true;
155
+ }
156
+
137
157
  async getSubtitleTracks(torrent, fileIndex) {
138
158
  const sourceKey = torrent?.sourceKey;
139
159
  if (!sourceKey) {
@@ -65,6 +65,12 @@ export const Command = {
65
65
  CANCEL_READ: "cancel-read",
66
66
  /** Pre-fetch the head and tail a codec probe needs. */
67
67
  PREFETCH_EDGES: "prefetch-edges",
68
+ /**
69
+ * Fetch one whole file using only the room the viewer's own reading leaves —
70
+ * a soundtrack or subtitle file they may switch to later. Returns as soon as
71
+ * the work is under way.
72
+ */
73
+ FILL_FILE: "fill-file",
68
74
  /** The text subtitle tracks a file carries, for the viewer's menu. */
69
75
  SUBTITLE_TRACKS: "subtitle-tracks",
70
76
  /**