@torrent-tv/proxy 2.66.1 → 2.68.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.1",
3
+ "version": "2.68.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({
@@ -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,213 @@
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, stallsSeenOn } 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, stallsSeen?: (infoHash: string) => number }} [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 stallsSeen = typeof options.stallsSeen === "function" ? options.stallsSeen : stallsSeenOn;
127
+ const work = fill(torrent, file, fileIndex, chunkBytes, isBlocked, stallsSeen).finally(() => {
128
+ running.delete(key);
129
+ });
130
+ running.set(key, work);
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * @param {object} torrent
136
+ * @param {object} file
137
+ * @param {number} fileIndex
138
+ * @param {number} chunkBytes
139
+ * @param {(infoHash: string) => boolean} isBlocked
140
+ * @param {(infoHash: string) => number} stallsSeen
141
+ * @returns {Promise<void>}
142
+ */
143
+ async function fill(torrent, file, fileIndex, chunkBytes, isBlocked, stallsSeen) {
144
+ const startedAt = Date.now();
145
+ const infoHash = String(torrent?.infoHash ?? "");
146
+ let read = 0;
147
+ let stoodAsideMs = 0;
148
+ // The stall count this fill last saw. A chunk is fetched only when it has not
149
+ // moved since the previous one.
150
+ let quietSince = stallsSeen(infoHash);
151
+ logger.info(
152
+ `background-fill: "${String(file.name).slice(0, 40)}" (${(file.length / 1e6).toFixed(1)}MB) will be ` +
153
+ "fetched whole while the viewer's own reading leaves room"
154
+ );
155
+ for (let start = 0; start < file.length; start += chunkBytes) {
156
+ // Stand aside while anything the viewer is watching is waiting on the
157
+ // swarm — AND for as long after it as it takes for a quiet stretch to
158
+ // pass. Pausing only DURING a stall is not enough: on a swarm delivering
159
+ // exactly what the film needs, this still takes bandwidth between stalls,
160
+ // and the stalls themselves are the proof there was none to spare. Field
161
+ // 2026-08-31, the case that forced this: 200-600 KB/s delivered against
162
+ // the 399 KB/s the film eats, one piece waited 101 s, and the picture
163
+ // stood still 145.6 s.
164
+ //
165
+ // "A quiet stretch" is measured, not chosen: the stall counter must not
166
+ // have moved while the previous chunk was being fetched. On a starving
167
+ // swarm it moves constantly and this stops altogether, which is the right
168
+ // answer — there is no spare room to use.
169
+ while (isBlocked(infoHash) || stallsSeen(infoHash) !== quietSince) {
170
+ stoodAsideMs += STAND_ASIDE_MS;
171
+ await pause(STAND_ASIDE_MS);
172
+ // Re-baselined after the pause, so a stretch that passes without a new
173
+ // stall lets the fill go on. Without this it could never resume.
174
+ quietSince = stallsSeen(infoHash);
175
+ }
176
+ // The torrent may have been destroyed under us — a viewer who left, the
177
+ // disk sweep, a restart. Reading a destroyed file throws, and there is
178
+ // nothing here worth an error.
179
+ if (!torrent?.files?.[fileIndex]) {
180
+ return;
181
+ }
182
+ // Taken BEFORE the read, and deliberately not refreshed after it: a stall
183
+ // that happens while this chunk is in flight must still be visible to the
184
+ // next iteration. Refreshing afterwards erased exactly that evidence, which
185
+ // is the defect a test caught here.
186
+ quietSince = stallsSeen(infoHash);
187
+ const bytes = await readRange(file, start, Math.min(start + chunkBytes, file.length) - 1);
188
+ if (bytes === 0) {
189
+ logger.info(
190
+ `background-fill: "${String(file.name).slice(0, 40)}" stopped at ` +
191
+ `${(start / 1e6).toFixed(1)}MB — the read returned nothing`
192
+ );
193
+ return;
194
+ }
195
+ read += bytes;
196
+ }
197
+ logger.info(
198
+ `background-fill: "${String(file.name).slice(0, 40)}" is on disk — ${(read / 1e6).toFixed(1)}MB in ` +
199
+ `${((Date.now() - startedAt) / 1000).toFixed(0)}s, of which ${(stoodAsideMs / 1000).toFixed(0)}s ` +
200
+ "was spent standing aside for the viewer; a switch to it will not wait for the swarm"
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Whether a file is being filled right now. For tests and for the log.
206
+ *
207
+ * @param {string} sourceKey
208
+ * @param {number} fileIndex
209
+ * @returns {boolean}
210
+ */
211
+ export function fillIsRunning(sourceKey, fileIndex) {
212
+ return running.has(`${sourceKey}:${fileIndex}`);
213
+ }
@@ -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,80 @@ 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
+ * How many stalls a torrent's readers have had, ever. Only differences between
622
+ * two readings of it mean anything.
623
+ *
624
+ * @type {Map<string, number>}
625
+ */
626
+ const stallsSeen = new Map();
627
+
628
+ /**
629
+ * Whether any reader on this torrent is waiting for a piece right now.
630
+ *
631
+ * @param {string} infoHash
632
+ * @returns {boolean}
633
+ */
634
+ export function readersAreBlockedOn(infoHash) {
635
+ return (blockedReaders.get(infoHash) ?? 0) > 0;
636
+ }
637
+
638
+ /**
639
+ * How many times a reader on this torrent has been blocked since the process
640
+ * started.
641
+ *
642
+ * Exists so that work of lower importance can ask "did the viewer stall while I
643
+ * was busy?" — which is a different and stricter question than "is the viewer
644
+ * stalled right now". On a swarm delivering exactly what the film needs, a
645
+ * background fetch that only pauses DURING a stall still takes bandwidth
646
+ * between them, and the stalls are the proof it had none to spare. Field
647
+ * 2026-08-31: the swarm delivered 200-600 KB/s against the 399 KB/s the film
648
+ * needs, and the picture stood still 145.6 s.
649
+ *
650
+ * @param {string} infoHash
651
+ * @returns {number}
652
+ */
653
+ export function stallsSeenOn(infoHash) {
654
+ return stallsSeen.get(infoHash) ?? 0;
655
+ }
656
+
657
+ /**
658
+ * @param {string} infoHash
659
+ * @param {number} delta
660
+ * @returns {void}
661
+ */
662
+ function countBlockedReader(infoHash, delta) {
663
+ if (!infoHash) {
664
+ return;
665
+ }
666
+ if (delta > 0) {
667
+ stallsSeen.set(infoHash, (stallsSeen.get(infoHash) ?? 0) + 1);
668
+ }
669
+ const next = (blockedReaders.get(infoHash) ?? 0) + delta;
670
+ if (next > 0) {
671
+ blockedReaders.set(infoHash, next);
672
+ return;
673
+ }
674
+ blockedReaders.delete(infoHash);
675
+ }
676
+
603
677
  function noteSupplyWait(key, label, waitedMs) {
604
678
  const history = supplyWaits.get(key) ?? [];
605
679
  history.push({ waitedMs, at: Date.now() });
@@ -623,8 +697,14 @@ function noteSupplyWait(key, label, waitedMs) {
623
697
  });
624
698
  logger.info(
625
699
  `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)` +
700
+ // "Interruption", not "wait": several readers walk one file the picture
701
+ // and each audio rendition so one missing piece produces one stall and
702
+ // several waits. Saying how many of each is what makes the figure readable;
703
+ // reporting the waits alone made `2 measured` look like two interruptions
704
+ // 3 ms apart, and the demanded speed came out at 4422x.
705
+ `to survive this swarm (worst stall ${demand.worstWaitSec.toFixed(2)}s, one every ` +
706
+ `${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
707
+ `from ${demand.waits} wait(s)) — ` +
628
708
  `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
629
709
  // What steering the blocked piece onto another peer bought, as the
630
710
  // difference between the waits where it placed something and the waits
@@ -890,6 +970,15 @@ export async function* readFragments({
890
970
  }
891
971
 
892
972
  const waitStartedAt = Date.now();
973
+ // What the WHOLE torrent received while this one piece was missing. It is
974
+ // the reading that separates the two causes a wait can have, and neither
975
+ // could be told from the other before: bytes arriving briskly throughout
976
+ // mean the swarm had capacity and this piece was stuck behind the wire
977
+ // that reserved it — the blocked-piece tail; bytes barely moving mean
978
+ // there was nothing to be had, and no reordering of requests would have
979
+ // helped. Asked of 2026-08-31, when a session with 4-5 peers of 38 known
980
+ // waited 41.32 s at worst and the log could not say which it was.
981
+ const downloadedAtWaitStart = Number(torrent?.downloaded) || 0;
893
982
  // The reader is blocked, so this piece is now the only thing that matters
894
983
  // on this torrent: hand it to the fastest wires that hold it. A block is
895
984
  // reserved for exactly one wire, and the read ends when the slowest
@@ -955,9 +1044,16 @@ export async function* readFragments({
955
1044
  // moment ago may now be the fastest one available.
956
1045
  pushToFastest();
957
1046
  }, 500);
1047
+ // Counted for exactly as long as this reader is inside the wait, so that
1048
+ // background work can stand aside while the viewer's own reading is
1049
+ // starving. The `finally` is what makes it safe: a cancelled or failed
1050
+ // read must not leave the torrent looking permanently blocked, which
1051
+ // would stop that background work for the rest of the session.
1052
+ countBlockedReader(torrent?.infoHash, 1);
958
1053
  try {
959
1054
  await whenPieceReady(torrent, pieceIndex, cancellation);
960
1055
  } finally {
1056
+ countBlockedReader(torrent?.infoHash, -1);
961
1057
  clearInterval(supplyProbe);
962
1058
  }
963
1059
  // What a reader spent waiting for data, attributed to the exact piece. A
@@ -999,11 +1095,21 @@ export async function* readFragments({
999
1095
  }
1000
1096
  if (waitedMs >= PIECE_WAIT_LOG_MS) {
1001
1097
  const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
1098
+ // Two rates, side by side, and no verdict word between them: the swarm's
1099
+ // own delivery during the wait against what this piece managed. Both are
1100
+ // measured; which one a reader calls "the cause" follows from the pair
1101
+ // without a threshold having to be chosen here. Far apart means the
1102
+ // bytes were flowing and this piece was not among them; close together,
1103
+ // or both near zero, means there was nothing to deliver.
1104
+ const swarmBytes = Math.max(0, (Number(torrent?.downloaded) || 0) - downloadedAtWaitStart);
1105
+ const swarmKbps = Math.round(swarmBytes / 1024 / (waitedMs / 1000));
1002
1106
  logger.info(
1003
1107
  `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
1004
1108
  `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
1005
1109
  `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
1006
- `— ${rateKbps}KB/s on this piece; ` +
1110
+ `— ${rateKbps}KB/s on this piece while the swarm delivered ` +
1111
+ `${swarmKbps}KB/s (${(swarmBytes / 1024 / 1024).toFixed(1)}MB) across the torrent, ` +
1112
+ `${Number(torrent?.numPeers) || 0} peers connected; ` +
1007
1113
  (supply
1008
1114
  ? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
1009
1115
  `${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
  /**