@torrent-tv/proxy 2.105.10 → 2.105.11

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.105.10",
3
+ "version": "2.105.11",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -483,7 +483,8 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
483
483
  *
484
484
  * `paused` is the library's own word for this and the path it already checks,
485
485
  * so nothing here fights it. The pause alone does not close what is already
486
- * open, so the peers are let go by hand; the data stays exactly where it is.
486
+ * open, so the peers are let go by hand; the data stays exactly where it is,
487
+ * and so do the addresses dialled, for the next rejoin (`rememberSwarmAddresses`).
487
488
  *
488
489
  * @param {import("webtorrent").Torrent} torrent
489
490
  * @returns {number} Connections let go.
@@ -494,6 +495,8 @@ export function leaveSwarm(torrent) {
494
495
  }
495
496
  torrent.pause();
496
497
  const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
498
+ const remembered = rememberSwarmAddresses(torrent);
499
+ const unanswered = describeUnansweredRejoin(torrent);
497
500
  let closed = 0;
498
501
  for (const peer of [...(torrent._peers?.values?.() ?? [])]) {
499
502
  try {
@@ -505,11 +508,150 @@ export function leaveSwarm(torrent) {
505
508
  }
506
509
  logger.info(
507
510
  `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] left the swarm — its last reader went, ` +
508
- `${open} connection(s) open, ${closed} let go; the data stays and the next reader rejoins`
511
+ `${open} connection(s) open, ${closed} let go, ${remembered} address(es) kept for the next reader` +
512
+ `${unanswered}; the data stays and the next reader rejoins`
509
513
  );
510
514
  return closed;
511
515
  }
512
516
 
517
+ /**
518
+ * WebTorrent's own `maxConns` when the client states none (webtorrent 2.8.5,
519
+ * index.js). More remembered addresses than this could not be dialled at once.
520
+ */
521
+ const WEBTORRENT_DEFAULT_MAX_CONNS = 55;
522
+
523
+ /**
524
+ * The addresses a torrent dialled in its swarm, kept for every later rejoin.
525
+ *
526
+ * `leaveSwarm` destroys every peer, and a destroyed peer leaves the torrent's
527
+ * `_peers` and its dial queue with it, so a torrent that rejoined had nobody to
528
+ * dial: `resume()` only drains that empty queue, and nothing announces again
529
+ * until the periodic discovery round — fifteen minutes by default, longer when
530
+ * a tracker says so. Home Assistant 2026-10-23, torrent 507d1fa9: it rejoined
531
+ * with "0 connected of 0 known", the page got no segment for 73 s, and every
532
+ * later opening of the same film started with no peer (torrent-tv/meta#238).
533
+ *
534
+ * KEPT FOR THE LIFE OF THE TORRENT OBJECT and refreshed at each departure, not
535
+ * consumed by a rejoin: `addPeer` accepts a candidate, it does not connect one,
536
+ * and a torrent that leaves again before a candidate answered would otherwise
537
+ * take an empty snapshot and lose them all.
538
+ *
539
+ * @type {WeakMap<object, string[]>}
540
+ */
541
+ const swarmAddresses = new WeakMap();
542
+
543
+ /**
544
+ * Torrents with a DHT lookup started by a rejoin and not yet answered: a
545
+ * second rejoin meanwhile does not start another.
546
+ *
547
+ * @type {WeakSet<object>}
548
+ */
549
+ const swarmLookups = new WeakSet();
550
+
551
+ /**
552
+ * When each torrent last rejoined, on the monotonic clock, and whether a peer
553
+ * and data have come since — what tells a restored swarm from a rejoin that
554
+ * dialled addresses nobody answers at any more.
555
+ *
556
+ * @type {WeakMap<object, { at: number, firstPeerSeconds: number | null, firstDataSeconds: number | null }>}
557
+ */
558
+ const swarmRejoins = new WeakMap();
559
+
560
+ /**
561
+ * Refresh the addresses kept for a torrent from the peers it has now, before
562
+ * they are let go. Outgoing peers only: an incoming peer's address carries the
563
+ * port its side connected FROM, which nobody listens on. Connected ones first,
564
+ * then the candidates still queued or connecting, then what was kept before;
565
+ * no address twice, and no more than the client may dial at once. A departure
566
+ * that finds no outgoing peer keeps the earlier list.
567
+ *
568
+ * @param {import("webtorrent").Torrent} torrent
569
+ * @returns {number} How many addresses are kept.
570
+ */
571
+ export function rememberSwarmAddresses(torrent) {
572
+ const limit = Number(torrent?.client?.maxConns) > 0 ? Number(torrent.client.maxConns) : WEBTORRENT_DEFAULT_MAX_CONNS;
573
+ const outgoing = [...(torrent?._peers?.values?.() ?? [])]
574
+ .filter((peer) => (peer?.type === "tcpOutgoing" || peer?.type === "utpOutgoing") &&
575
+ typeof peer.addr === "string" && peer.addr.length > 0);
576
+ const kept = [];
577
+ const seen = new Set();
578
+ for (const addr of [
579
+ ...outgoing.filter((peer) => peer.wire).map((peer) => peer.addr),
580
+ ...outgoing.filter((peer) => !peer.wire).map((peer) => peer.addr),
581
+ ...(swarmAddresses.get(torrent) ?? [])
582
+ ]) {
583
+ if (kept.length >= limit) break;
584
+ if (seen.has(addr)) continue;
585
+ seen.add(addr);
586
+ kept.push(addr);
587
+ }
588
+ if (kept.length > 0) swarmAddresses.set(torrent, kept);
589
+ return kept.length;
590
+ }
591
+
592
+ /**
593
+ * Said when a torrent leaves its swarm again: what the previous rejoin got.
594
+ *
595
+ * @param {import("webtorrent").Torrent} torrent
596
+ * @returns {string}
597
+ */
598
+ function describeUnansweredRejoin(torrent) {
599
+ const rejoin = swarmRejoins.get(torrent);
600
+ if (!rejoin || rejoin.firstDataSeconds !== null) return "";
601
+ const seconds = ((performance.now() - rejoin.at) / 1000).toFixed(1);
602
+ return rejoin.firstPeerSeconds === null
603
+ ? ` (no peer and no data in the ${seconds}s since it rejoined)`
604
+ : ` (a peer after ${rejoin.firstPeerSeconds.toFixed(1)}s, no data in the ${seconds}s since it rejoined)`;
605
+ }
606
+
607
+ /**
608
+ * Ask the DHT for the torrent's peers again, unless a lookup a rejoin started
609
+ * is still running. A private torrent has no DHT (WebTorrent passes `dht:
610
+ * false` to its discovery), and the kept addresses are then all it has.
611
+ *
612
+ * @param {import("webtorrent").Torrent} torrent
613
+ * @param {() => string} label
614
+ * @returns {string} What was done, for the rejoin line.
615
+ */
616
+ function lookUpSwarm(torrent, label) {
617
+ const dht = torrent.discovery?.dht;
618
+ if (!dht || typeof dht.lookup !== "function" || typeof torrent.infoHash !== "string") return "no DHT";
619
+ if (swarmLookups.has(torrent)) return "DHT lookup already running";
620
+ swarmLookups.add(torrent);
621
+ try {
622
+ // Found peers arrive through the discovery's own "peer" listener, which
623
+ // hands them to `addPeer`. The callback only ends the lookup.
624
+ dht.lookup(torrent.infoHash, (error) => {
625
+ swarmLookups.delete(torrent);
626
+ if (error) logger.warn(`torrent-pool: [${label()}] DHT lookup after rejoin failed: ${error?.message ?? error}`);
627
+ });
628
+ } catch (error) {
629
+ swarmLookups.delete(torrent);
630
+ logger.warn(`torrent-pool: [${label()}] DHT lookup after rejoin failed: ${error?.message ?? error}`);
631
+ return "DHT lookup failed";
632
+ }
633
+ return "DHT lookup started";
634
+ }
635
+
636
+ /**
637
+ * Note the first peer, or the first downloaded bytes, since the torrent's last
638
+ * rejoin, once each.
639
+ *
640
+ * @param {import("webtorrent").Torrent} torrent
641
+ * @param {"peer" | "data"} kind
642
+ * @returns {boolean} Whether this was the first of its kind since the rejoin.
643
+ */
644
+ export function noteAfterRejoin(torrent, kind) {
645
+ const rejoin = swarmRejoins.get(torrent);
646
+ if (!rejoin) return false;
647
+ const field = kind === "peer" ? "firstPeerSeconds" : "firstDataSeconds";
648
+ if (rejoin[field] !== null) return false;
649
+ rejoin[field] = (performance.now() - rejoin.at) / 1000;
650
+ logger.info(`torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] after rejoin: first ` +
651
+ `${kind} after ${rejoin[field].toFixed(1)}s`);
652
+ return true;
653
+ }
654
+
513
655
  /**
514
656
  * Take the swarm back for a torrent a reader has just arrived at.
515
657
  *
@@ -517,6 +659,12 @@ export function leaveSwarm(torrent) {
517
659
  * back. Both are acted on at the moment they happen, and neither is discovered
518
660
  * by asking.
519
661
  *
662
+ * `resume()` in WebTorrent 2.8.5 only clears `paused` and dials what is in the
663
+ * torrent's queue, which `leaveSwarm` emptied; it announces nothing. So the
664
+ * addresses kept at the departure are handed back, and the DHT is asked again.
665
+ * The trackers stay on their own schedule: each sets its own interval, and an
666
+ * announce at every rejoin would not respect it.
667
+ *
520
668
  * @param {import("webtorrent").Torrent} torrent
521
669
  * @returns {boolean} Whether it had to be taken back.
522
670
  */
@@ -525,15 +673,33 @@ export function rejoinSwarm(torrent) {
525
673
  return false;
526
674
  }
527
675
  torrent.resume();
528
- // resume() rediscovers protocol peers but does not recreate HTTP seeds
529
- // explicitly closed by leaveSwarm(). Their URLs remain in the metadata.
676
+ const label = () => String(torrent.infoHash ?? "?").slice(0, 8);
677
+ // resume() does not recreate HTTP seeds explicitly closed by leaveSwarm().
678
+ // Their URLs remain in the metadata.
530
679
  if (torrent.client?.enableWebSeeds !== false && typeof torrent.addWebSeed === "function") {
531
680
  for (const url of torrent.urlList ?? []) {
532
681
  if (!torrent._peers?.has(url)) torrent.addWebSeed(url);
533
682
  }
534
683
  }
684
+ swarmRejoins.set(torrent, { at: performance.now(), firstPeerSeconds: null, firstDataSeconds: null });
685
+ // A torrent held whole has nothing to fetch from anybody: dialling its swarm
686
+ // again would only bring back the connections leaving it was for.
687
+ const whole = torrent.progress === 1;
688
+ const addresses = whole ? [] : swarmAddresses.get(torrent) ?? [];
689
+ let handed = 0;
690
+ if (typeof torrent.addPeer === "function") {
691
+ for (const addr of addresses) {
692
+ try {
693
+ if (torrent.addPeer(addr, "rejoin")) handed += 1;
694
+ } catch {
695
+ // An address the library refuses now: the others are still worth dialling.
696
+ }
697
+ }
698
+ }
699
+ const lookup = whole ? "held whole, no DHT lookup" : lookUpSwarm(torrent, label);
535
700
  logger.info(
536
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — a reader arrived`
701
+ `torrent-pool: [${label()}] rejoined the swarm — a reader arrived; ` +
702
+ `${handed} of ${addresses.length} kept address(es) handed back, ${lookup}`
537
703
  );
538
704
  return true;
539
705
  }
@@ -1712,6 +1878,11 @@ export class TorrentPool {
1712
1878
  );
1713
1879
  });
1714
1880
 
1881
+ // What a rejoin restored: its first connection and its first downloaded
1882
+ // bytes, once each per rejoin (torrent-tv/meta#238).
1883
+ torrent.on("wire", () => noteAfterRejoin(torrent, "peer"));
1884
+ torrent.on("download", () => noteAfterRejoin(torrent, "data"));
1885
+
1715
1886
  torrent.on("warning", (warning) => {
1716
1887
  logger.warn(`torrent-pool: [${label()}] warning: ${formatWarning(warning)}`);
1717
1888
  });