@torrent-tv/proxy 2.15.0 → 2.15.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 2.15.1
2
+
3
+ - **Fix**: A magnet that never found its metadata no longer makes the same film unplayable from its own `.torrent`. One infohash is one torrent, so the second add is refused and the pool takes the one already there — which is right when it is ready and wrong when it is not: a magnet whose swarm has not answered has no file list, and everything bound to it is answered 404. Measured 2026-08-15 on the addon host: a magnet with no reachable trackers was added first, the film's own `.torrent` then joined that empty torrent instead of replacing it, `/stream` answered 404, the encoder died on its first read, and the film stayed unplayable until the proxy was restarted. A `.torrent` carries the file list, the piece hashes and the trackers outright, so when it meets a torrent with no metadata it now replaces it; two magnets still wait, because neither has anything the other lacks.
4
+
1
5
  ## 2.15.0
2
6
 
3
7
  - **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.15.0",
3
+ "version": "2.15.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -315,6 +315,30 @@ function computeDefaultDiskCap(storePath) {
315
315
  * @param {string} source - Magnet URI or base64-encoded .torrent file.
316
316
  * @returns {string | Buffer}
317
317
  */
318
+ /**
319
+ * What to do when WebTorrent refuses an add because that infohash is already
320
+ * here: take the one that exists, replace it, or wait for it to be ready.
321
+ *
322
+ * The same content arrives as a `.torrent` and as a magnet — different pool
323
+ * keys, one swarm — so a duplicate is ordinary. What is NOT ordinary is a
324
+ * duplicate with no metadata: a magnet whose swarm has not answered has no file
325
+ * list, and every request bound to it is answered 404 for as long as it lives.
326
+ * If this add carries metadata — a `.torrent` holds the files, the piece hashes
327
+ * and the trackers outright — waiting for the swarm to supply what is already
328
+ * in our hands is waiting for nothing. Measured 2026-08-15: one magnet with no
329
+ * reachable peers made the same film unplayable from its own `.torrent` until
330
+ * the proxy was restarted.
331
+ *
332
+ * @param {{ existingIsReady: boolean, incomingHasMetadata: boolean }} params
333
+ * @returns {"adopt" | "replace" | "wait"}
334
+ */
335
+ export function duplicateAddDecision({ existingIsReady, incomingHasMetadata }) {
336
+ if (existingIsReady) {
337
+ return "adopt";
338
+ }
339
+ return incomingHasMetadata ? "replace" : "wait";
340
+ }
341
+
318
342
  function decodeTorrentSource(sourceType, source) {
319
343
  if (sourceType === "magnet") {
320
344
  return source;
@@ -972,11 +996,58 @@ export class TorrentPool {
972
996
  this.#pending.delete(key);
973
997
  resolve(existing);
974
998
  };
975
- if (existing.ready) {
999
+ const decision = duplicateAddDecision({
1000
+ existingIsReady: existing.ready === true,
1001
+ incomingHasMetadata: Buffer.isBuffer(torrentId)
1002
+ });
1003
+ if (decision === "adopt") {
976
1004
  settle();
977
- } else {
978
- existing.once("ready", settle);
1005
+ return;
1006
+ }
1007
+ // The one already here has no metadata, and THIS add carries it —
1008
+ // a `.torrent` holds the file list, the piece hashes and the
1009
+ // trackers outright. Waiting for the other one to become ready is
1010
+ // waiting on the swarm to supply what is already in our hands, and
1011
+ // when the swarm cannot (a magnet with no reachable peers) it never
1012
+ // arrives: measured 2026-08-15, a magnet added first left every
1013
+ // later attempt at the same infohash — including the complete
1014
+ // `.torrent` — bound to an empty torrent, `/stream` answering 404
1015
+ // and playback impossible until the proxy was restarted.
1016
+ if (decision === "replace") {
1017
+ logger.info(
1018
+ `torrent-pool: [${dupMatch[1].slice(0, 8)}] replacing a torrent with no metadata ` +
1019
+ `with the .torrent just given, which has it`
1020
+ );
1021
+ // Everything this class remembers about the old one goes with it,
1022
+ // or a later request finds it through a map and works with a
1023
+ // torrent the client no longer has.
1024
+ for (const [otherKey, value] of this.torrents) {
1025
+ if (value === existing) {
1026
+ this.torrents.delete(otherKey);
1027
+ }
1028
+ }
1029
+ this.fileUsageByTorrent.delete(existing);
1030
+ this.#lastAccess.delete(existing);
1031
+ this.#readPositionByTorrent.delete(existing);
1032
+ this.client.remove(existing, { destroyStore: true }, () => {
1033
+ this.client.add(torrentId, {
1034
+ store: SharedPieceStore,
1035
+ storeCacheSlots: 0,
1036
+ storeOpts: { memoryBytes: this.#memoryBytes }
1037
+ }, (replacement) => {
1038
+ this.torrents.set(key, replacement);
1039
+ this.#lastAccess.set(replacement, Date.now());
1040
+ this.#attachSwarmDiagnostics(dupMatch[1].slice(0, 8), replacement);
1041
+ this.#pending.delete(key);
1042
+ resolve(replacement);
1043
+ });
1044
+ });
1045
+ return;
979
1046
  }
1047
+ // Both are magnets: there is nothing here the other does not have,
1048
+ // so wait for the swarm — the caller's own bound answers if it
1049
+ // takes too long.
1050
+ existing.once("ready", settle);
980
1051
  return;
981
1052
  }
982
1053
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file One infohash, two sources: what happens when the second add is refused.
3
+ *
4
+ * The same film arrives as a magnet and as a `.torrent`. WebTorrent refuses the
5
+ * second add — one infohash, one torrent — and the pool then has to decide what
6
+ * to hand back. Adopting the one already there is right when it is ready, and
7
+ * wrong when it is not: a magnet whose swarm has not answered has no file list,
8
+ * so everything bound to it is answered 404.
9
+ *
10
+ * Measured 2026-08-15 on the addon host: a magnet with no reachable trackers
11
+ * was added first and never became ready; the same film's own `.torrent`, which
12
+ * carries the file list, the piece hashes and the trackers, then joined that
13
+ * empty torrent instead of replacing it. `/stream` answered 404, the encoder
14
+ * died on its first read, and the film stayed unplayable until the proxy was
15
+ * restarted — one bad magnet poisoning every later attempt at that infohash.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { duplicateAddDecision } from "../services/torrent-pool.js";
21
+
22
+ test("a ready torrent is adopted, whatever the new source carries", () => {
23
+ assert.equal(
24
+ duplicateAddDecision({ existingIsReady: true, incomingHasMetadata: true }),
25
+ "adopt",
26
+ "one swarm per infohash: a second copy would download the same pieces twice"
27
+ );
28
+ assert.equal(
29
+ duplicateAddDecision({ existingIsReady: true, incomingHasMetadata: false }),
30
+ "adopt"
31
+ );
32
+ });
33
+
34
+ test("an unready torrent is replaced when this add brings the metadata", () => {
35
+ assert.equal(
36
+ duplicateAddDecision({ existingIsReady: false, incomingHasMetadata: true }),
37
+ "replace",
38
+ "the .torrent holds what the swarm was being asked for; waiting for it is waiting for nothing"
39
+ );
40
+ });
41
+
42
+ test("two magnets wait, because neither has anything the other lacks", () => {
43
+ assert.equal(
44
+ duplicateAddDecision({ existingIsReady: false, incomingHasMetadata: false }),
45
+ "wait",
46
+ "replacing here would only restart the same search, and the caller's own bound answers"
47
+ );
48
+ });