@torrent-tv/proxy 2.83.2 → 2.83.4

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.
@@ -317,30 +317,6 @@ export class TorrentWorkerClient {
317
317
  return this.#caller.call(Command.LIST_FILES, { sourceKey });
318
318
  }
319
319
 
320
- /**
321
- * Claim a file so it is not evicted while being read.
322
- *
323
- * @param {string} sourceKey
324
- * @param {number} fileIndex
325
- * @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
326
- */
327
- async acquireFile(sourceKey, fileIndex) {
328
- return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
329
- }
330
-
331
- /**
332
- * Drop one claim taken with {@link acquireFile}.
333
- *
334
- * Named by claim rather than by file: several readers hold the same file at
335
- * once, and releasing "the file" released somebody else's hold.
336
- *
337
- * @param {string} claimId
338
- * @returns {Promise<void>}
339
- */
340
- async releaseFile(claimId) {
341
- await this.#caller.call(Command.RELEASE_FILE, { claimId });
342
- }
343
-
344
320
  /**
345
321
  * Live download figures for the progress display.
346
322
  *
@@ -11,11 +11,9 @@
11
11
  *
12
12
  * Two accommodations are needed, and both are deliberate:
13
13
  *
14
- * - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
15
- * nothing the caller inspects, so the command is dispatched and not awaited.
16
- * `acquireFile` hands back a release function exactly as before, which sends
17
- * its own command when called. Awaiting them would mean touching every call
18
- * site for no observable gain.
14
+ * - **`prioritizeByteRange` stays synchronous.** It returns nothing the caller
15
+ * inspects, so the command is dispatched and not awaited. Awaiting it would
16
+ * mean touching every call site for no observable gain.
19
17
  * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
20
18
  * thread, so the worker keys them. Callers that have one pass it; the rest
21
19
  * get one derived from the source itself, so the identity stays stable
@@ -85,34 +83,6 @@ export class WorkerTorrentPool {
85
83
  return this.#client.allowSpillBytes(bytes);
86
84
  }
87
85
 
88
- acquireFile(torrent, fileIndex) {
89
- const sourceKey = torrent?.sourceKey;
90
- if (!sourceKey) {
91
- return () => undefined;
92
- }
93
- // Dispatched, not awaited — callers use the result immediately and inspect
94
- // nothing. But the release MUST NOT overtake it: both are ordinary messages
95
- // to the worker, and if release arrives first the reader count drops to zero
96
- // while a read is still running. The idle sweep then removes the torrent AND
97
- // its downloaded data out from under the encoder — field 2026-08-02:
98
- // "removed idle torrent ... and its store" mid-playback, after which every
99
- // read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
100
- // Chaining the release onto the acquire keeps them in order.
101
- const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
102
- let released = false;
103
- return () => {
104
- if (released) {
105
- return;
106
- }
107
- released = true;
108
- // Release the claim this call opened, not "the file" — waiting for the
109
- // acquire is also what tells us which claim that is.
110
- void acquired
111
- .then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
112
- .catch(() => undefined);
113
- };
114
- }
115
-
116
86
  /**
117
87
  * Bytes every torrent here has moved.
118
88
  *
@@ -47,10 +47,6 @@
47
47
  export const Command = {
48
48
  /** Add (or join) a torrent; resolves when metadata is ready. */
49
49
  ADD_SOURCE: "add-source",
50
- /** Claim a file for reading, so it is not evicted while in use. */
51
- ACQUIRE_FILE: "acquire-file",
52
- /** Drop a claim; the worker applies its own idle-removal policy. */
53
- RELEASE_FILE: "release-file",
54
50
  /** File list and metadata for a source. */
55
51
  LIST_FILES: "list-files",
56
52
  /** Live download figures for the progress display. */
@@ -11,10 +11,9 @@
11
11
  * with the torrent for this thread, which is exactly the problem being solved.
12
12
  *
13
13
  * The existing `TorrentPool` is reused wholesale rather than reimplemented. It
14
- * already carries the parts that took field failures to get right — refcounted
15
- * file claims, idle removal, the global disk cap with LRU eviction, seek-aware
16
- * piece prioritisation, adaptive upload — and none of that changes by moving
17
- * threads.
14
+ * already carries the parts that took field failures to get right — idle
15
+ * removal, the global disk cap with LRU eviction, seek-aware piece
16
+ * prioritisation, adaptive upload — and none of that changes by moving threads.
18
17
  */
19
18
 
20
19
  // MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
@@ -25,7 +24,6 @@ import { isUsableTorrentHandle } from "./handle-state.js";
25
24
  import "./install-webrtc-shim.js";
26
25
  import { parentPort, workerData } from "node:worker_threads";
27
26
  import { createSendStream } from "./channel.js";
28
- import { createFileClaims } from "./file-claims.js";
29
27
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
28
  import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
29
  import {
@@ -36,6 +34,7 @@ import {
36
34
  warmResumePosition
37
35
  } from "./container-tracks.js";
38
36
  import { fillFileInBackground } from "./background-fill.js";
37
+ import { demandFor } from "../download/registry.js";
39
38
  import { CompletedFiles, completedFilesRoot } from "../files/CompletedFiles.js";
40
39
  import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../files/piece-from-whole-file.js";
41
40
  import { Command, Event } from "./protocol.js";
@@ -93,8 +92,6 @@ const torrentsByKey = new Map();
93
92
  * @type {Map<string, { sourceType: string, source: string }>}
94
93
  */
95
94
  const sourceRecipes = new Map();
96
- /** File claims, each with its own identity — see `file-claims.js`. */
97
- const fileClaims = createFileClaims();
98
95
  /** In-flight reads, so a cancel can stop one mid-body. */
99
96
  const readsById = new Map();
100
97
 
@@ -127,6 +124,26 @@ function log(message) {
127
124
  * @param {string} sourceKey
128
125
  * @returns {Promise<import("webtorrent").Torrent>}
129
126
  */
127
+ /**
128
+ * The torrent for a sourceKey IF there is one, and never one that has to be
129
+ * built to answer.
130
+ *
131
+ * The other half of the pair above, for the questions that are about something
132
+ * going away: a departure is not a reason to add anything, and the answer
133
+ * "there is nothing here" is a complete answer to them.
134
+ *
135
+ * @param {string} sourceKey
136
+ * @returns {Promise<import("webtorrent").Torrent | null>}
137
+ */
138
+ async function knownTorrent(sourceKey) {
139
+ const pending = torrentsByKey.get(sourceKey);
140
+ if (!pending) {
141
+ return null;
142
+ }
143
+ const torrent = await pending.catch(() => null);
144
+ return isUsableTorrentHandle(torrent) ? torrent : null;
145
+ }
146
+
130
147
  async function requireTorrent(sourceKey) {
131
148
  const pending = torrentsByKey.get(sourceKey);
132
149
  if (!pending) {
@@ -241,15 +258,6 @@ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }
241
258
  const sender = createSendStream({ port: parentPort, requestId: id });
242
259
  readsById.set(id, sender);
243
260
 
244
- // Hold the file for as long as this read runs. The caller also acquires it,
245
- // but that acquire and its release are separate messages from another thread
246
- // and can be reordered; this one cannot, because it lives entirely inside the
247
- // read. Without it the idle sweep saw a zero reader count and removed the
248
- // torrent AND its store mid-read — field 2026-08-02: "removed idle torrent
249
- // ... and its store", after which every subsequent read hung and ffmpeg got
250
- // an empty input.
251
- const releaseRead = pool.acquireFile(torrent, fileIndex);
252
-
253
261
  const rangeStart = start ?? 0;
254
262
  const rangeEnd = end ?? file.length - 1;
255
263
 
@@ -290,7 +298,6 @@ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }
290
298
  // Any fragment still awaiting confirmation will never get one now; settling
291
299
  // it here releases its pin rather than leaking a held slot.
292
300
  settleFragment(id);
293
- releaseRead();
294
301
  if (!failed) {
295
302
  sender.end();
296
303
  }
@@ -356,27 +363,6 @@ async function runCommand(command, params, id) {
356
363
  }));
357
364
  }
358
365
 
359
- case Command.ACQUIRE_FILE: {
360
- const torrent = await requireTorrent(params.sourceKey);
361
- // Every acquire is its own claim. Sharing one per file meant the first
362
- // reader to finish released the hold while others were still reading.
363
- return fileClaims.open(
364
- params.sourceKey,
365
- params.fileIndex,
366
- pool.acquireFile(torrent, params.fileIndex)
367
- );
368
- }
369
-
370
- case Command.RELEASE_FILE: {
371
- const released = fileClaims.close(params.claimId);
372
- if (!released) {
373
- // Not fatal — but it means a release arrived twice or after teardown,
374
- // and silence here is what let the previous scheme look healthy.
375
- log(`release for unknown file claim ${params.claimId}`);
376
- }
377
- return released;
378
- }
379
-
380
366
  case Command.HELD_TORRENTS: {
381
367
  // Which films this proxy has right now, and how much of each. Answered
382
368
  // from the live client rather than from the main thread's map of
@@ -537,13 +523,18 @@ async function runCommand(command, params, id) {
537
523
  }
538
524
 
539
525
  case Command.PRIORITY_MAP: {
540
- const torrent = await requireTorrent(params.sourceKey);
541
- pool.applyPriorityMap(
542
- torrent,
543
- params.fileIndex,
544
- params.zones,
545
- params.durationSeconds
546
- );
526
+ // A MAP WITH NOTHING IN IT MUST NOT BRING A TORRENT BACK. It is what is
527
+ // said when the last viewer of a file leaves, which is also when the
528
+ // torrent may be on its way out — and `requireTorrent` rebuilds a dead
529
+ // handle from its recipe, so asking that way would re-add a torrent in
530
+ // order to be told that nothing is wanted of it.
531
+ const zones = Array.isArray(params.zones) ? params.zones : [];
532
+ const torrent = zones.length === 0
533
+ ? await knownTorrent(params.sourceKey)
534
+ : await requireTorrent(params.sourceKey);
535
+ if (torrent) {
536
+ pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
537
+ }
547
538
  return true;
548
539
  }
549
540
 
@@ -590,7 +581,6 @@ async function runCommand(command, params, id) {
590
581
  }
591
582
 
592
583
  case Command.DESTROY_ALL: {
593
- fileClaims.closeAll();
594
584
  torrentsByKey.clear();
595
585
  sourceRecipes.clear();
596
586
  await pool.destroyAll();
@@ -827,11 +817,11 @@ function describePieceBuffers() {
827
817
  * @returns {void}
828
818
  */
829
819
  function warmActiveFiles(sourceKey, torrent) {
830
- const usage = pool.fileUsageByTorrent.get(torrent);
831
- if (!usage) {
832
- return;
833
- }
834
- for (const fileIndex of usage.keys()) {
820
+ // The files anything is stated for — a viewer's own picture and soundtrack
821
+ // through the priority map, and the ends of a file that is open. It replaces
822
+ // a count of readers, which said the same thing by keeping a second copy of
823
+ // it.
824
+ for (const fileIndex of demandFor(torrent).register.files()) {
835
825
  const key = `${sourceKey}:${fileIndex}`;
836
826
  // A trigger that arrives while the previous pass is still walking is
837
827
  // dropped, not queued. `verified` fires per piece, so on a fast download
@@ -1009,18 +999,21 @@ async function keepWholeFiles() {
1009
999
  if (!infoHash || !Array.isArray(torrent.files)) {
1010
1000
  continue;
1011
1001
  }
1012
- const usage = pool.fileUsageByTorrent?.get?.(torrent);
1002
+ // What anybody wants of this torrent. A file something is stated for is a
1003
+ // file somebody may be reading, and this is the same list the reader counts
1004
+ // used to give.
1005
+ const wanted = new Set(demandFor(torrent).register.files());
1013
1006
  for (const [fileIndex, file] of torrent.files.entries()) {
1014
1007
  const key = `${infoHash}/${fileIndex}`;
1015
1008
  if (file?.done !== true || completedFiles.find(infoHash, fileIndex) || beingKept.has(key)) {
1016
1009
  continue;
1017
1010
  }
1018
- // NOT WHILE SOMEBODY IS READING IT. Writing a film out is a read of the
1019
- // whole of it and a write of the whole of it — a gigabyte and a half on
1020
- // the file this was measured against — and doing that beside a viewer
1021
- // takes the disk and the piece store from them for nothing they asked
1022
- // for. The file is complete; it will still be complete when they leave.
1023
- if (usage?.has?.(fileIndex)) {
1011
+ // NOT WHILE ANYBODY WANTS IT. Writing a film out is a read of the whole
1012
+ // of it and a write of the whole of it — a gigabyte and a half on the
1013
+ // file this was measured against — and doing that beside a viewer takes
1014
+ // the disk and the piece store from them for nothing they asked for. The
1015
+ // file is complete; it will still be complete when they leave.
1016
+ if (wanted.has(fileIndex)) {
1024
1017
  continue;
1025
1018
  }
1026
1019
  beingKept.add(key);
@@ -1074,7 +1067,7 @@ async function keepWholeFiles() {
1074
1067
  const isWhole =
1075
1068
  torrent.done === true &&
1076
1069
  torrent.files.every((unused, fileIndex) => completedFiles.find(infoHash, fileIndex) !== null);
1077
- if (isWhole && !(usage?.size > 0)) {
1070
+ if (isWhole && wanted.size === 0) {
1078
1071
  logger.info(
1079
1072
  `whole files: "${torrent.name}" is downloaded whole and saved — removing the torrent, keeping the files`
1080
1073
  );
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @file THE TWO ENDS OF A FILE ARE THE FILE'S, NOT A READ'S.
3
+ *
4
+ * A container keeps its directory at one end or the other: `ftyp` and an EBML
5
+ * header at the front, and for an MP4 that was not written for streaming the
6
+ * `moov` at the very back. Nothing can be read of such a file until they have
7
+ * arrived, and they go on being wanted for as long as it is open — a player
8
+ * asks for the file's shape again at every seek.
9
+ *
10
+ * Until 2.83.4 they were wanted by nobody. The prefetch that fetches them is an
11
+ * ordinary read, and a read withdraws what it states the moment it finishes, so
12
+ * the ends of an open film were held by nothing at all once the codec probe was
13
+ * done.
14
+ */
15
+
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { TorrentPool, stateFileEdges, withdrawFileEdges } from "../services/torrent-pool.js";
19
+ import { demandFor, forgetTorrent } from "../services/download/registry.js";
20
+ import { Urgency } from "../services/demand/index.js";
21
+
22
+ /** A torrent of two files, of which the second is the film. */
23
+ function torrentOf({ length = 1_000_000 } = {}) {
24
+ return {
25
+ infoHash: `hash-${Math.random().toString(36).slice(2)}`,
26
+ pieceLength: 1024,
27
+ files: [
28
+ { offset: 0, length: 4096, name: "notes.nfo" },
29
+ { offset: 4096, length, name: "film.mkv" }
30
+ ],
31
+ _selections: { _items: [] },
32
+ _select() {},
33
+ _deselect() {},
34
+ critical() {}
35
+ };
36
+ }
37
+
38
+ /**
39
+ * @param {object} torrent
40
+ * @returns {object[]}
41
+ */
42
+ function edges(torrent) {
43
+ return demandFor(torrent)
44
+ .register.windows()
45
+ .filter((one) => String(one.claimant).startsWith("file-edges:"));
46
+ }
47
+
48
+ test("one byte is claimed at each end, which is one piece at each end", () => {
49
+ const torrent = torrentOf({ length: 1_000_000 });
50
+ try {
51
+ assert.equal(stateFileEdges(torrent, 1, Urgency.TAIL), true);
52
+
53
+ const stated = edges(torrent);
54
+ assert.equal(stated.length, 2);
55
+ const head = stated.find((one) => String(one.claimant).endsWith(":head"));
56
+ const tail = stated.find((one) => String(one.claimant).endsWith(":tail"));
57
+ // File-relative, like everything else stated about a file: the claim says
58
+ // "the first byte of this file" and "its last", and what a byte costs to
59
+ // fetch — a whole piece — is the swarm's business and nobody else's.
60
+ assert.deepEqual([head.byteStart, head.byteEnd], [0, 0]);
61
+ assert.deepEqual([tail.byteStart, tail.byteEnd], [999_999, 999_999]);
62
+ assert.equal(head.fileIndex, 1);
63
+ assert.equal(tail.fileIndex, 1);
64
+ } finally {
65
+ forgetTorrent(torrent);
66
+ }
67
+ });
68
+
69
+ test("while somebody is waiting for them they are urgent, and afterwards they are kept", () => {
70
+ const torrent = torrentOf();
71
+ try {
72
+ // The playback plan cannot answer until the file has said what is in it,
73
+ // and a person is watching a loading screen for as long as that takes.
74
+ stateFileEdges(torrent, 1, Urgency.NEAR);
75
+ assert.deepEqual(
76
+ edges(torrent).map((one) => one.urgency),
77
+ [Urgency.NEAR, Urgency.NEAR]
78
+ );
79
+
80
+ // Once it has answered, nobody is waiting — but a seek will want them
81
+ // again, so they stay stated at the level of something nobody is waiting
82
+ // for. Re-stating replaces; it does not pile a second claim on the first.
83
+ stateFileEdges(torrent, 1, Urgency.TAIL, { lower: true });
84
+ assert.equal(edges(torrent).length, 2);
85
+ assert.deepEqual(
86
+ edges(torrent).map((one) => one.urgency),
87
+ [Urgency.TAIL, Urgency.TAIL]
88
+ );
89
+ } finally {
90
+ forgetTorrent(torrent);
91
+ }
92
+ });
93
+
94
+ test("each file of a torrent has its own ends", () => {
95
+ const torrent = torrentOf();
96
+ try {
97
+ stateFileEdges(torrent, 0, Urgency.TAIL);
98
+ stateFileEdges(torrent, 1, Urgency.NEAR);
99
+ assert.equal(edges(torrent).length, 4);
100
+
101
+ withdrawFileEdges(torrent, 0);
102
+ assert.deepEqual(
103
+ edges(torrent).map((one) => one.fileIndex),
104
+ [1, 1]
105
+ );
106
+ } finally {
107
+ forgetTorrent(torrent);
108
+ }
109
+ });
110
+
111
+ test("a file of unknown length has no ends to claim", () => {
112
+ const torrent = torrentOf();
113
+ try {
114
+ assert.equal(stateFileEdges(torrent, 7, Urgency.TAIL), false);
115
+ assert.equal(stateFileEdges({ files: [{ length: 0 }] }, 0, Urgency.TAIL), false);
116
+ assert.equal(edges(torrent).length, 0);
117
+ } finally {
118
+ forgetTorrent(torrent);
119
+ }
120
+ });
121
+
122
+ test("a map with nothing in it takes the ends back too", () => {
123
+ const torrent = torrentOf();
124
+ const applyPriorityMap = TorrentPool.prototype.applyPriorityMap;
125
+ try {
126
+ stateFileEdges(torrent, 1, Urgency.TAIL);
127
+ applyPriorityMap.call(null, torrent, 1, [{ from: 0, to: 600, priority: 100 }], 600);
128
+ assert.equal(edges(torrent).length, 2);
129
+
130
+ // Nobody wants this file any more, and its ends are kept only for as long
131
+ // as it is open.
132
+ applyPriorityMap.call(null, torrent, 1, [], 600);
133
+ assert.equal(edges(torrent).length, 0);
134
+ assert.deepEqual(demandFor(torrent).register.windows(), []);
135
+ } finally {
136
+ forgetTorrent(torrent);
137
+ }
138
+ });
139
+
140
+ test("another file's departure leaves these ends alone", () => {
141
+ const torrent = torrentOf();
142
+ const applyPriorityMap = TorrentPool.prototype.applyPriorityMap;
143
+ try {
144
+ stateFileEdges(torrent, 0, Urgency.TAIL);
145
+ stateFileEdges(torrent, 1, Urgency.TAIL);
146
+
147
+ applyPriorityMap.call(null, torrent, 0, [], 600);
148
+
149
+ assert.deepEqual(
150
+ edges(torrent).map((one) => one.fileIndex),
151
+ [1, 1]
152
+ );
153
+ } finally {
154
+ forgetTorrent(torrent);
155
+ }
156
+ });
157
+
158
+ test("a warm-up cannot take the urgency away from a plan that is waiting", () => {
159
+ // The two callers of the same ends: a playback plan, which somebody is
160
+ // watching a loading screen for, and a warm-up, which by its whole purpose
161
+ // nobody is waiting for. The warm-up fires off the files beside the picture
162
+ // without awaiting them, so it can arrive second — and lowering the plan's
163
+ // level there would put a person behind a film somebody else is watching.
164
+ const torrent = torrentOf();
165
+ try {
166
+ stateFileEdges(torrent, 1, Urgency.NEAR);
167
+ stateFileEdges(torrent, 1, Urgency.TAIL);
168
+
169
+ assert.deepEqual(
170
+ edges(torrent).map((one) => one.urgency),
171
+ [Urgency.NEAR, Urgency.NEAR]
172
+ );
173
+ } finally {
174
+ forgetTorrent(torrent);
175
+ }
176
+ });
177
+
178
+ test("the read that is over is what puts them back down", () => {
179
+ const torrent = torrentOf();
180
+ try {
181
+ stateFileEdges(torrent, 1, Urgency.NEAR);
182
+ stateFileEdges(torrent, 1, Urgency.TAIL, { lower: true });
183
+
184
+ assert.deepEqual(
185
+ edges(torrent).map((one) => one.urgency),
186
+ [Urgency.TAIL, Urgency.TAIL]
187
+ );
188
+ } finally {
189
+ forgetTorrent(torrent);
190
+ }
191
+ });