@torrent-tv/proxy 2.107.1 → 2.107.2

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.107.1",
3
+ "version": "2.107.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -46,3 +46,22 @@
46
46
  * for reusable torrents, source files, and generated segments.
47
47
  */
48
48
  export const IDLE_KEEP_MS = 60 * 60 * 1000;
49
+
50
+ /**
51
+ * How long a torrent keeps its swarm after its last reader has gone, in
52
+ * milliseconds.
53
+ *
54
+ * THE SAME UNKNOWN AS {@link IDLE_KEEP_MS} — whether the viewer comes back —
55
+ * so it is stated here, beside it, and not where it is used. A reload, a tab
56
+ * closed and opened again, or a pause between two transcode sessions is a
57
+ * return within minutes, and a return that finds its connections still open
58
+ * dials nobody.
59
+ *
60
+ * SHORTER THAN THE HOUR because what it holds costs more than disk. A held
61
+ * swarm keeps its connections open and accepts new ones: WebTorrent limits only
62
+ * the peers it dials, and field 2026-09-11 had 249 connected at the start of one
63
+ * viewing and 596 at the end. Five minutes is the owner's figure for the short
64
+ * return (torrent-tv/meta#240); nothing measured derives it either, and the
65
+ * returns recorded by `services/storage/returns.js` are what will.
66
+ */
67
+ export const SWARM_HOLD_MS = 5 * 60 * 1000;
@@ -8,7 +8,7 @@
8
8
  * download.
9
9
  */
10
10
 
11
- import { IDLE_KEEP_MS } from "../storage/keep.js";
11
+ import { IDLE_KEEP_MS, SWARM_HOLD_MS } from "../storage/keep.js";
12
12
  import dns from "node:dns/promises";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
@@ -454,6 +454,76 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
454
454
  return { bytesPerSec: floor, reason: "active readers, not choke-starved" };
455
455
  }
456
456
 
457
+ /**
458
+ * Swarms kept for a while after their last reader went: the timer that lets
459
+ * each go, and when it was set.
460
+ *
461
+ * @type {Map<object, { timer: ReturnType<typeof setTimeout>, since: number }>}
462
+ */
463
+ const swarmHolds = new Map();
464
+
465
+ /**
466
+ * Keep the swarm of a torrent whose last reader has just left, for
467
+ * {@link SWARM_HOLD_MS}, and let it go then if nobody has come back.
468
+ *
469
+ * A reader who comes back within it — a reload, a tab opened again — finds every
470
+ * connection still open, the incoming ones too, which the addresses kept by
471
+ * `leaveSwarm` cannot give back. While held, the torrent downloads nothing:
472
+ * it is added with `deselect: true` and nothing is stated for it.
473
+ *
474
+ * The clock runs from the first departure, as the idle timer's does
475
+ * (torrent-tv/meta#95): a second departure while held does not extend it.
476
+ *
477
+ * A torrent held whole is let go at once. It has nothing to fetch, and a whole
478
+ * file with its swarm open is the 596 connections of 2026-09-11.
479
+ *
480
+ * @param {import("webtorrent").Torrent} torrent
481
+ * @param {number} [holdMs]
482
+ * @returns {boolean} Whether the swarm is held.
483
+ */
484
+ export function holdSwarm(torrent, holdMs = SWARM_HOLD_MS) {
485
+ if (!torrent || torrent.destroyed || torrent.paused === true || swarmHolds.has(torrent)) {
486
+ return false;
487
+ }
488
+ if (torrent.progress === 1) {
489
+ leaveSwarm(torrent);
490
+ return false;
491
+ }
492
+ const timer = setTimeout(() => {
493
+ swarmHolds.delete(torrent);
494
+ if (!torrent.destroyed && !isWanted(torrent)) {
495
+ leaveSwarm(torrent);
496
+ }
497
+ }, holdMs);
498
+ timer.unref?.();
499
+ swarmHolds.set(torrent, { timer, since: performance.now() });
500
+ const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
501
+ logger.info(
502
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] its last reader went — ` +
503
+ `the swarm is held for ${Math.round(holdMs / 1000)}s, ${open} connection(s) open`
504
+ );
505
+ return true;
506
+ }
507
+
508
+ /**
509
+ * Stop holding a torrent's swarm, saying nothing: for a torrent that is going.
510
+ *
511
+ * Its timer holds the torrent strongly, as the idle timer did when it kept
512
+ * removed torrents alive (field 2026-10-04).
513
+ *
514
+ * @param {object} torrent
515
+ * @returns {number | null} Milliseconds it was held, or null when it was not.
516
+ */
517
+ export function releaseSwarmHold(torrent) {
518
+ const held = swarmHolds.get(torrent);
519
+ if (!held) {
520
+ return null;
521
+ }
522
+ clearTimeout(held.timer);
523
+ swarmHolds.delete(torrent);
524
+ return performance.now() - held.since;
525
+ }
526
+
457
527
  /**
458
528
  * Let go of the swarm of a torrent whose last reader has just left.
459
529
  *
@@ -481,6 +551,9 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
481
551
  * spontaneously: it is a consequence of a departure, and a torrent nobody has
482
552
  * read yet has nothing to depart from.
483
553
  *
554
+ * It runs when the period {@link holdSwarm} keeps the swarm for has passed with
555
+ * nobody back, or at the departure itself for a torrent held whole.
556
+ *
484
557
  * `paused` is the library's own word for this and the path it already checks,
485
558
  * so nothing here fights it. The pause alone does not close what is already
486
559
  * open, so the peers are let go by hand; the data stays exactly where it is,
@@ -669,7 +742,21 @@ export function noteAfterRejoin(torrent, kind) {
669
742
  * @returns {boolean} Whether it had to be taken back.
670
743
  */
671
744
  export function rejoinSwarm(torrent) {
672
- if (!torrent || torrent.destroyed || torrent.paused !== true) {
745
+ if (!torrent || torrent.destroyed) {
746
+ return false;
747
+ }
748
+ // Held, not let go: every connection is still open, so there is nothing to
749
+ // resume and nobody to dial.
750
+ const heldMs = releaseSwarmHold(torrent);
751
+ if (heldMs !== null) {
752
+ const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
753
+ logger.info(
754
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] a reader came back after ` +
755
+ `${(heldMs / 1000).toFixed(1)}s — the swarm was held, ${open} connection(s) open`
756
+ );
757
+ return false;
758
+ }
759
+ if (torrent.paused !== true) {
673
760
  return false;
674
761
  }
675
762
  torrent.resume();
@@ -2200,11 +2287,11 @@ export class TorrentPool {
2200
2287
  this.#lastAccess.set(torrent, Date.now());
2201
2288
  rejoinSwarm(torrent);
2202
2289
  }
2203
- // A PROXY THAT NEEDS NOTHING FROM A SWARM SHOULD NOT BE IN THAT SWARM. The
2204
- // data stays on disk; the next thing that wants a byte of it takes the
2205
- // swarm back.
2290
+ // A PROXY THAT NEEDS NOTHING FROM A SWARM SHOULD NOT BE IN THAT SWARM —
2291
+ // once the viewer has had the time to come back (`holdSwarm`). The data
2292
+ // stays on disk; the next thing that wants a byte of it takes the swarm back.
2206
2293
  if (swarm === "let go") {
2207
- leaveSwarm(torrent);
2294
+ holdSwarm(torrent);
2208
2295
  }
2209
2296
  if (onTheClock) {
2210
2297
  this.#scheduleIdleRemoval(torrent);
@@ -2335,6 +2422,7 @@ export class TorrentPool {
2335
2422
  */
2336
2423
  #forgetBookkeeping(torrent) {
2337
2424
  this.#cancelIdleRemoval(torrent);
2425
+ releaseSwarmHold(torrent);
2338
2426
  this.#lastAccess.delete(torrent);
2339
2427
  this.#stallSince.delete(torrent);
2340
2428
  this.#stallReportedAt.delete(torrent);
@@ -2510,6 +2598,9 @@ export class TorrentPool {
2510
2598
  }
2511
2599
  this.#idleTimers.clear();
2512
2600
  this.#lastAccess.clear();
2601
+ for (const torrent of this.torrents.values()) {
2602
+ releaseSwarmHold(torrent);
2603
+ }
2513
2604
 
2514
2605
  if (!this.client || this.client.destroyed) {
2515
2606
  return;