@torrent-tv/proxy 2.9.88 → 2.9.90

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.
@@ -306,17 +306,6 @@ export class TorrentPool {
306
306
  */
307
307
  #readPositionByTorrent = new Map();
308
308
 
309
- /**
310
- * Lowest piece currently selected for download, per torrent and fileIndex.
311
- *
312
- * Needed because selection is not readable back from WebTorrent, and a seek
313
- * backward has to know whether the pieces it wants were deselected by an
314
- * earlier seek forward.
315
- *
316
- * @type {Map<import("webtorrent").Torrent, Map<number, number>>}
317
- */
318
- #selectedFromPiece = new Map();
319
-
320
309
  /** Global disk cap in bytes (0 = disabled). */
321
310
  #maxDiskBytes = 0;
322
311
 
@@ -982,8 +971,20 @@ export class TorrentPool {
982
971
  }
983
972
 
984
973
  /**
985
- * Update WebTorrent piece selection to match the current usage map.
986
- * Files with at least one consumer are selected; all others are deselected.
974
+ * Drop the pieces of files nobody is reading from the download set.
975
+ *
976
+ * It does NOT select the files that ARE in use, and that is the point. What a
977
+ * file needs is decided by the readers walking it: each one claims a moving
978
+ * window around its own read head and gives it back when it ends (see
979
+ * `torrent-worker/piece-reader.js`). Selecting the whole file here as well
980
+ * put a second, contradictory claim on the same pieces — one that covered
981
+ * everything and therefore always outranked the window — and it was re-made
982
+ * on every single `/stream` request, so a seek's prioritisation survived at
983
+ * most until the next one. Measured consequence: a seek to 89.1% of a 4.7 GB
984
+ * film waited 93 s while the swarm fetched 2.47 GB in file order.
985
+ *
986
+ * A file with no reader is deselected outright, which is what stops a torrent
987
+ * downloading files the viewer never opened.
987
988
  *
988
989
  * @param {import("webtorrent").Torrent} torrent
989
990
  * @param {Map<number, number>} usage - fileIndex → refCount.
@@ -995,14 +996,7 @@ export class TorrentPool {
995
996
  }
996
997
  for (let index = 0; index < torrent.files.length; index += 1) {
997
998
  const file = torrent.files[index];
998
- if (!file) {
999
- continue;
1000
- }
1001
- const shouldSelect = (usage.get(index) ?? 0) > 0;
1002
- if (shouldSelect) {
1003
- if (typeof file.select === "function") {
1004
- file.select();
1005
- }
999
+ if (!file || (usage.get(index) ?? 0) > 0) {
1006
1000
  continue;
1007
1001
  }
1008
1002
  if (typeof file.deselect === "function") {
@@ -1012,42 +1006,27 @@ export class TorrentPool {
1012
1006
  }
1013
1007
 
1014
1008
  /**
1015
- * Bias the torrent's download toward the current read position, so a seek
1016
- * downloads the seek target first instead of waiting behind the sequential
1017
- * backlog (which caused ~15-18 s stalls when seeking into an undownloaded
1018
- * region). Called on every range request.
1009
+ * Record where a file is being read from.
1019
1010
  *
1020
- * Two levers, matched to how WebTorrent's picker actually works:
1011
+ * This used to also decide what the torrent should download, and that was the
1012
+ * mistake: it was one of THREE places claiming pieces for the same file — the
1013
+ * whole-file `file.select()` in `#syncSelections`, this method, and the reader
1014
+ * itself — and they overwrote each other on every request. The claim now
1015
+ * belongs to the reader alone, which holds a moving window around its own read
1016
+ * head and gives it back when it ends
1017
+ * (`torrent-worker/piece-reader.js`); several readers on one file therefore
1018
+ * produce the union of their windows instead of the last caller's opinion.
1021
1019
  *
1022
- * 1. **Demote the gap BEHIND the playhead** `deselect(fileStart, playhead-1)`.
1023
- * The picker scans each selection sequentially from its first UNdownloaded
1024
- * piece; with the whole file selected, a far forward seek would make it
1025
- * fetch the undownloaded gap behind the new position first. Removing that
1026
- * gap from the selection makes the scan START at the playhead, so all peer
1027
- * capacity goes to the pieces the player needs next. This only STOPS
1028
- * fetching the gap; already-downloaded pieces stay on disk (deleting them
1029
- * is Disk hygiene Level 2), and a later backward seek re-selects the region
1030
- * via this same call. The whole file is re-selected by `file.select()` on
1031
- * the next `acquireFile`, so nothing is permanently dropped.
1032
- *
1033
- * 2. **Critical read-ahead window** — `critical(playhead, playhead+window)`.
1034
- * `critical` does not reorder the scan; it enables HOTSWAP (re-request a
1035
- * block from a faster peer when a slow one reserved it) over the near
1036
- * window. Reset first so criticality stays a moving window rather than
1037
- * accumulating over the whole file across seeks.
1038
- *
1039
- * Scope: single active reader per file (≈100% today). The multi-viewer union
1040
- * window — demote only where behind for ALL sessions — is deferred (roadmap
1041
- * item 23); here the latest read position wins.
1042
- *
1043
- * The pinned head/tail (prefetchFileEdges, codec probe) is downloaded up front
1044
- * and lives forward of the playhead (tail) or is already on disk (head), so
1045
- * demotion never costs the probe its data.
1020
+ * What is left here is bookkeeping the readers cannot do: `getFileStats`
1021
+ * reports how much of the window ahead of the read head is still missing, so
1022
+ * the viewer can be shown how long a resume will take, and a jump in the read
1023
+ * position is logged because a seek that never reaches the torrent is
1024
+ * invisible otherwise.
1046
1025
  *
1047
1026
  * @param {import("webtorrent").Torrent} torrent
1048
1027
  * @param {number} fileIndex
1049
1028
  * @param {number} byteStart - Start offset within the file.
1050
- * @param {number} [windowBytes] - Bytes ahead of `byteStart` to mark critical.
1029
+ * @param {number} [windowBytes] - Unused; kept so callers need not change.
1051
1030
  * @param {{ wholeFileRead?: boolean }} [options] - `wholeFileRead` marks a
1052
1031
  * request that carried no byte range, i.e. one that merely opens the file at
1053
1032
  * 0 rather than asking to read from there. See the guard below.
@@ -1060,24 +1039,17 @@ export class TorrentPool {
1060
1039
  windowBytes = PRIORITY_WINDOW_BYTES,
1061
1040
  options = {}
1062
1041
  ) {
1063
- if (!torrent || typeof torrent.critical !== "function" || !Array.isArray(torrent.files)) {
1064
- return;
1065
- }
1066
- const pieceLength = Number(torrent.pieceLength);
1067
- if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
1042
+ if (!torrent || !Array.isArray(torrent.files)) {
1068
1043
  return;
1069
1044
  }
1070
1045
  const file = torrent.files[fileIndex];
1071
1046
  if (!file) {
1072
1047
  return;
1073
1048
  }
1074
- const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
1075
1049
  const fileLength = Number(file.length);
1076
1050
  if (!Number.isFinite(fileLength) || fileLength <= 0) {
1077
1051
  return;
1078
1052
  }
1079
- const fileStartPiece = Math.floor(fileOffset / pieceLength);
1080
- const fileEndPiece = Math.floor((fileOffset + fileLength - 1) / pieceLength);
1081
1053
 
1082
1054
  const safeStart = Math.max(0, Number(byteStart) || 0);
1083
1055
 
@@ -1120,70 +1092,6 @@ export class TorrentPool {
1120
1092
  );
1121
1093
  }
1122
1094
 
1123
- const absStart = fileOffset + safeStart;
1124
- const playheadPiece = Math.floor(absStart / pieceLength);
1125
- const absWindowEnd = Math.min(
1126
- fileOffset + fileLength - 1,
1127
- absStart + Math.max(1, windowBytes) - 1
1128
- );
1129
- const windowEndPiece = Math.floor(absWindowEnd / pieceLength);
1130
-
1131
- // (1) Re-select from the playhead when it moved BACK behind what an earlier
1132
- // seek deselected. `deselect` removes pieces from the download set, and
1133
- // `critical` does NOT put them back — it only flags pieces already
1134
- // selected. So without this, a seek forward followed by a seek backward
1135
- // leaves the target pieces wanted by nobody: the encoder waits on data
1136
- // the torrent was told to stop fetching, and waits forever.
1137
- // Only on a backward move, so repeat calls do not pile up selections.
1138
- let selectedFrom = this.#selectedFromPiece.get(torrent)?.get(fileIndex);
1139
- if (selectedFrom === undefined || playheadPiece < selectedFrom) {
1140
- try {
1141
- torrent.select(playheadPiece, fileEndPiece, 1);
1142
- } catch {
1143
- // Best effort — never break streaming because selection failed.
1144
- }
1145
- let perFile = this.#selectedFromPiece.get(torrent);
1146
- if (!perFile) {
1147
- perFile = new Map();
1148
- this.#selectedFromPiece.set(torrent, perFile);
1149
- }
1150
- perFile.set(fileIndex, playheadPiece);
1151
- selectedFrom = playheadPiece;
1152
- }
1153
-
1154
- // (2) Demote the gap behind the playhead so the picker scans forward from
1155
- // the read position. Only when there IS a gap (not at the file start).
1156
- if (playheadPiece > fileStartPiece && typeof torrent.deselect === "function") {
1157
- try {
1158
- torrent.deselect(fileStartPiece, playheadPiece - 1);
1159
- // `deselect` subtracts the interval and copies the selection's `offset`
1160
- // — how many pieces from its start are already downloaded — into what
1161
- // remains. The picker scans from `from + offset`, so the surviving
1162
- // selection starts scanning far past its own end and can never yield a
1163
- // piece: measured with the library's own `Selections`, deselecting
1164
- // 0-522 from `{0-587, offset 226}` leaves `{523-587, offset 226}`,
1165
- // i.e. a scan starting at piece 749 of 587. The seek target ends up
1166
- // wanted by nobody. Re-selecting the same range replaces that dead
1167
- // entry with a fresh one whose offset is 0.
1168
- torrent.select(playheadPiece, fileEndPiece, 1);
1169
- this.#selectedFromPiece.get(torrent)?.set(fileIndex, playheadPiece);
1170
- } catch {
1171
- // Best effort — never break streaming because demotion failed.
1172
- }
1173
- }
1174
-
1175
- // (3) Reset criticality to a moving read-ahead window (hotswap over the near
1176
- // pieces), so it does not accumulate over the whole file across seeks.
1177
- if (Array.isArray(torrent._critical)) {
1178
- torrent._critical.length = 0;
1179
- }
1180
- if (windowEndPiece >= playheadPiece) {
1181
- try {
1182
- torrent.critical(playheadPiece, windowEndPiece);
1183
- } catch {
1184
- // Best effort.
1185
- }
1186
- }
1187
1095
  }
1188
1096
 
1189
1097
  /**
@@ -22,6 +22,145 @@
22
22
 
23
23
  import { findSharedStore } from "../piece-store/shared-piece-store.js";
24
24
 
25
+ /**
26
+ * How far ahead of the read head pieces are asked for.
27
+ *
28
+ * A read is open-ended — ffmpeg opens its input as `bytes <position>-<EOF>` and
29
+ * keeps it for the whole film — so taking the requested range literally asks
30
+ * for everything from the seek point to the end of the file at once. That is
31
+ * what a seek used to do: the swarm was told the entire tail was wanted, went
32
+ * at it from its first missing piece, and the one piece the decoder was blocked
33
+ * on arrived only when the sequential scan reached it. Measured on a 4.7 GB
34
+ * film: a seek to 89.1% took 93 s and pulled 2.47 GB.
35
+ *
36
+ * So the reader asks for a window and moves it as it goes. The size is a
37
+ * compromise the caller cannot yet express: the right unit is seconds of
38
+ * playback (duration and size are both known — to the transcode session, not to
39
+ * this thread), and 32 MB is about 34 s of a 1080p film but only a few seconds
40
+ * of a disc remux. Sizing it from the real byte rate is a follow-up; what
41
+ * matters here is that it is bounded and moving rather than "to the end".
42
+ */
43
+ const READ_WINDOW_BYTES = 32 * 1024 * 1024;
44
+
45
+ /**
46
+ * How many pieces past the one being waited for are marked critical.
47
+ *
48
+ * `critical` means "a reader is blocked on this now" — it is what lets a piece
49
+ * jump the sequential scan. Marking a whole range critical, as this did, says
50
+ * it about hundreds of pieces at once and the signal stops meaning anything.
51
+ * WebTorrent's own reader marks `min(1 MB / pieceLength, 2)` pieces, i.e. the
52
+ * one under the head and at most two more; the same rule is used here.
53
+ *
54
+ * @param {number} pieceLength
55
+ * @returns {number}
56
+ */
57
+ function criticalRunLength(pieceLength) {
58
+ return Math.min(Math.floor((1024 * 1024) / Math.max(1, pieceLength)), 2);
59
+ }
60
+
61
+ /**
62
+ * The pieces a reader at `pieceIndex` wants next, clamped to its own range.
63
+ *
64
+ * @param {{ pieceIndex: number, lastPiece: number, windowPieces: number }} params
65
+ * @returns {{ from: number, to: number }}
66
+ */
67
+ export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
68
+ const span = Math.max(1, windowPieces);
69
+ return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
70
+ }
71
+
72
+ /**
73
+ * Add this reader's window to the download set as a stream selection.
74
+ *
75
+ * `_select`/`_deselect` with the stream flag are what WebTorrent's own
76
+ * `FileIterator` uses; there is no public call for it, because the public
77
+ * `select` produces the merging, interval-subtracted kind whose bookkeeping
78
+ * cannot express "one of several readers wants this". Falls back to the public
79
+ * call if a future version drops the private one.
80
+ *
81
+ * @param {import("webtorrent").Torrent} torrent
82
+ * @param {{ from: number, to: number }} window
83
+ * @returns {void}
84
+ */
85
+ function claimWindow(torrent, { from, to }) {
86
+ try {
87
+ if (typeof torrent._select === "function") {
88
+ torrent._select(from, to, 1, null, true);
89
+ } else if (typeof torrent.select === "function") {
90
+ torrent.select(from, to, 1);
91
+ }
92
+ } catch {
93
+ // Best effort — never fail a read because selection bookkeeping refused.
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Take this reader's window back out of the download set.
99
+ *
100
+ * The bounds must match the ones given to {@link claimWindow} exactly: a stream
101
+ * selection is removed by equality, not by overlap.
102
+ *
103
+ * @param {import("webtorrent").Torrent} torrent
104
+ * @param {{ from: number, to: number }} window
105
+ * @returns {void}
106
+ */
107
+ function releaseWindow(torrent, { from, to }) {
108
+ try {
109
+ if (typeof torrent._deselect === "function") {
110
+ torrent._deselect(from, to, true);
111
+ } else if (typeof torrent.deselect === "function") {
112
+ torrent.deselect(from, to);
113
+ }
114
+ } catch {
115
+ // Best effort.
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Mark the piece a reader is blocked on, clearing the mark it set before.
121
+ *
122
+ * Criticality is never cleared by WebTorrent itself, so a reader that walked a
123
+ * film would leave every piece of it marked. Only the indices this reader set
124
+ * are cleared, so a second reader's mark on the same piece is not stolen — and
125
+ * the flag is advisory anyway.
126
+ *
127
+ * @param {import("webtorrent").Torrent} torrent
128
+ * @param {number} from
129
+ * @param {number} to
130
+ * @param {{ from: number, to: number } | null} previous
131
+ * @returns {{ from: number, to: number } | null}
132
+ */
133
+ function markCritical(torrent, from, to, previous) {
134
+ if (previous && previous.from === from && previous.to === to) {
135
+ return previous;
136
+ }
137
+ if (previous) {
138
+ clearCritical(torrent, previous);
139
+ }
140
+ try {
141
+ torrent.critical?.(from, to);
142
+ } catch {
143
+ return null;
144
+ }
145
+ return { from, to };
146
+ }
147
+
148
+ /**
149
+ * Drop critical marks this reader set.
150
+ *
151
+ * @param {import("webtorrent").Torrent} torrent
152
+ * @param {{ from: number, to: number }} mark
153
+ * @returns {void}
154
+ */
155
+ function clearCritical(torrent, { from, to }) {
156
+ if (!Array.isArray(torrent._critical)) {
157
+ return;
158
+ }
159
+ for (let index = from; index <= to; index += 1) {
160
+ torrent._critical[index] = false;
161
+ }
162
+ }
163
+
25
164
  /**
26
165
  * Wait until a piece has been downloaded and verified.
27
166
  *
@@ -100,9 +239,19 @@ function whenPieceReady(torrent, index, cancellation) {
100
239
  * @param {number} params.start - Inclusive, relative to the file.
101
240
  * @param {number} params.end - Inclusive, relative to the file.
102
241
  * @param {{ isCancelled: () => boolean }} params.cancellation
242
+ * @param {number} [params.windowBytes] - How far ahead of the read head to ask
243
+ * the swarm for. Defaults to {@link READ_WINDOW_BYTES}; a caller that knows
244
+ * the media's byte rate should size it in seconds of playback instead.
103
245
  * @returns {AsyncGenerator<PieceFragment>}
104
246
  */
105
- export async function* readFragments({ torrent, fileIndex, start, end, cancellation }) {
247
+ export async function* readFragments({
248
+ torrent,
249
+ fileIndex,
250
+ start,
251
+ end,
252
+ cancellation,
253
+ windowBytes = READ_WINDOW_BYTES
254
+ }) {
106
255
  const store = findSharedStore(torrent);
107
256
  if (!store) {
108
257
  throw new Error("This torrent is not backed by a shared piece store.");
@@ -121,55 +270,95 @@ export async function* readFragments({ torrent, fileIndex, start, end, cancellat
121
270
  const firstPiece = Math.floor(absoluteStart / pieceLength);
122
271
  const lastPiece = Math.floor(absoluteEnd / pieceLength);
123
272
 
124
- // Ask for these pieces first. `select` puts them in the download set at all;
125
- // `critical` marks them as wanted now, which is what allows a piece to be
126
- // fetched out of sequential order for a reader that is waiting on it.
127
- if (typeof torrent.select === "function") {
128
- torrent.select(firstPiece, lastPiece, 1);
129
- }
130
- if (typeof torrent.critical === "function") {
131
- torrent.critical(firstPiece, lastPiece);
132
- }
273
+ // This reader owns what it asks for, and gives it back when it is done. The
274
+ // window is a STREAM selection: those are removed by exact bounds and several
275
+ // identical ones coexist WebTorrent's own source calls that "in a way a
276
+ // count" — so N readers on one torrent produce the union of their windows,
277
+ // and each one leaving takes away only its own. That is what makes several
278
+ // parallel readers (the codec probe's head and tail, subtitles, one input per
279
+ // viewer) cooperate instead of overwrite each other.
280
+ //
281
+ // The previous code selected the whole requested range, marked all of it
282
+ // critical, and never deselected anything — so ffmpeg's opening
283
+ // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
284
+ // later prioritisation could outrank it.
285
+ const windowPieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
286
+ const criticalRun = criticalRunLength(pieceLength);
287
+ /** @type {{ from: number, to: number } | null} */
288
+ let window = null;
289
+ /** @type {{ from: number, to: number } | null} */
290
+ let criticalMark = null;
133
291
 
134
- for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
135
- if (cancellation.isCancelled()) {
292
+ const moveWindowTo = (pieceIndex) => {
293
+ const next = readWindowFor({ pieceIndex, lastPiece, windowPieces });
294
+ if (window && window.from === next.from && window.to === next.to) {
136
295
  return;
137
296
  }
138
-
139
- const pieceStart = pieceIndex * pieceLength;
140
- const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
141
- const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
142
-
143
- await whenPieceReady(torrent, pieceIndex, cancellation);
144
-
145
- // Pinned BEFORE it is located, and before any await that could let an
146
- // eviction run: the offset is only meaningful while the piece is held.
147
- store.pin(pieceIndex);
148
- let located = null;
149
- try {
150
- located = await store.reside(pieceIndex);
151
- } catch (error) {
152
- store.unpin(pieceIndex);
153
- throw error;
297
+ if (window) {
298
+ releaseWindow(torrent, window);
154
299
  }
300
+ claimWindow(torrent, next);
301
+ window = next;
302
+ };
155
303
 
156
- if (!located) {
157
- store.unpin(pieceIndex);
158
- throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
159
- }
304
+ try {
305
+ for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
306
+ if (cancellation.isCancelled()) {
307
+ return;
308
+ }
160
309
 
161
- let releasedThisPiece = false;
162
- yield {
163
- pieceIndex,
164
- offset: located.offset + fromWithinPiece,
165
- length: toWithinPiece - fromWithinPiece + 1,
166
- release() {
167
- if (releasedThisPiece) {
168
- return;
169
- }
170
- releasedThisPiece = true;
310
+ const pieceStart = pieceIndex * pieceLength;
311
+ const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
312
+ const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
313
+
314
+ moveWindowTo(pieceIndex);
315
+
316
+ if (!torrent.bitfield?.get(pieceIndex)) {
317
+ // Blocked here and now — this is the one case `critical` is meant for.
318
+ criticalMark = markCritical(torrent, pieceIndex, Math.min(lastPiece, pieceIndex + criticalRun), criticalMark);
319
+ }
320
+
321
+ await whenPieceReady(torrent, pieceIndex, cancellation);
322
+
323
+ // Pinned BEFORE it is located, and before any await that could let an
324
+ // eviction run: the offset is only meaningful while the piece is held.
325
+ store.pin(pieceIndex);
326
+ let located = null;
327
+ try {
328
+ located = await store.reside(pieceIndex);
329
+ } catch (error) {
171
330
  store.unpin(pieceIndex);
331
+ throw error;
172
332
  }
173
- };
333
+
334
+ if (!located) {
335
+ store.unpin(pieceIndex);
336
+ throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
337
+ }
338
+
339
+ let releasedThisPiece = false;
340
+ yield {
341
+ pieceIndex,
342
+ offset: located.offset + fromWithinPiece,
343
+ length: toWithinPiece - fromWithinPiece + 1,
344
+ release() {
345
+ if (releasedThisPiece) {
346
+ return;
347
+ }
348
+ releasedThisPiece = true;
349
+ store.unpin(pieceIndex);
350
+ }
351
+ };
352
+ }
353
+ } finally {
354
+ // Reached on completion, on cancellation, on a throw, and when the consumer
355
+ // stops iterating — a window left behind would keep the swarm fetching for
356
+ // a reader that no longer exists.
357
+ if (window) {
358
+ releaseWindow(torrent, window);
359
+ }
360
+ if (criticalMark) {
361
+ clearCritical(torrent, criticalMark);
362
+ }
174
363
  }
175
364
  }