@torrent-tv/proxy 2.9.95 → 2.9.97

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,12 @@
1
+ ## 2.9.97
2
+
3
+ - **Fix**: The generous upload of 2.9.96 did not actually reach the moment it was written for. Only torrents with a registered reader were shown to the upload policy, and the first thing done with a new torrent — fetching the file's head and tail for the codec probe — reads through `createReadStream` without registering one. So for the whole of that wait, 8.36 s of the 11.46 s before playback in the measured session, the torrent looked unused and the upload stayed at the near-silent idle floor, during the exact seconds peers decide whether to serve us. A torrent in a hurry now counts whether or not anything is reading it. The selection is a named function of its own so it can be tested without a live swarm — the fault was in which torrents were considered, not in what was decided about them.
4
+
5
+ ## 2.9.96
6
+
7
+ - **New**: The proxy uploads generously at the two moments a viewer is provably waiting — when a torrent is added, and when the viewer seeks — for 25 s, which is two of BitTorrent's unchoke cycles. Peers serve those who serve them: each re-ranks its takers about every 10 s and opens a few slots to whoever uploaded most, plus one at random, so uploading a token 8-50 KB/s means being picked at random, one slot per cycle. Measured on a session where 96 peers were already connected within 2 s: 64 KB/s after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s — and the 16 MB the codec probe needs took **8.36 s of the 11.46 s** before playback could start. The existing reciprocity boost could not help, because it waits for the download to be all but dead (below 200 KB/s) with peers visibly choking us, and a ramp is neither: in that same session it first moved the limit 13.3 s after the torrent was added and reached the generous rate at 43.7 s, both after the wait they were meant to shorten. Seeding policy is otherwise unchanged — near-silence when nothing is being watched, a token upload while reading.
8
+ - **New**: Every encode run logs the exact ffmpeg command line. A failure is otherwise reported with ffmpeg's message and nothing about what it was asked to do, and the two are not always deducible from each other: a run died with `Cannot write moov atom before AC3 packets` although both muxing paths were then verified to handle a copied AC-3 track on that very host, so the arguments that run actually received are the missing evidence.
9
+
1
10
  ## 2.9.95
2
11
 
3
12
  - **New**: The rest of the file is downloaded in the background — but only while that cannot cost the viewer anything. The tail enters the download set at the lowest priority ONLY when every piece of the reader's near window is already on hand, and leaves it the moment one is missing, the window slides onto undownloaded content, or a seek moves it. Relying on priority ordering alone would be weaker: it decides which selection a wire is offered first, not what that wire already has outstanding, so a seek would still queue behind whatever was in flight. What it buys is a file that ends up downloaded while it is watched, making every later seek into it instant.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.95",
3
+ "version": "2.9.97",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -16,8 +16,7 @@
16
16
  "major": "npm whoami && npm version major && npm publish && git push --follow-tags",
17
17
  "start": "node ./bin/cli.js",
18
18
  "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
19
- "test": "node --test",
20
- "prepublishOnly": "npm test"
19
+ "test": "node --test"
21
20
  },
22
21
  "dependencies": {
23
22
  "@fastify/cors": "^11.2.0",
@@ -2125,6 +2125,15 @@ export class HlsSessionManager {
2125
2125
  );
2126
2126
  }
2127
2127
 
2128
+ // The exact command, every run. An encode failure is otherwise reported
2129
+ // with ffmpeg's message and nothing about what it was asked to do, and the
2130
+ // two are not always deducible from each other: 2026-08-04 a run died with
2131
+ // "Cannot write moov atom before AC3 packets" although both muxing paths
2132
+ // were verified to handle a copied AC-3 track on this very host, so the
2133
+ // arguments that run actually received are the missing evidence. One line
2134
+ // per run, and a run happens at most every few seconds.
2135
+ logger.info(`transcode ${session.id} ffmpeg ${args.join(" ")}`);
2136
+
2128
2137
  const ffmpeg = spawn(this.ffmpegBin, args, {
2129
2138
  cwd: session.dirPath,
2130
2139
  stdio: ["ignore", "pipe", "pipe"]
@@ -70,6 +70,28 @@ const UPLOAD_BOOST_BYTES = 512 * 1024; // raised to earn tit-for-tat u
70
70
  const UPLOAD_STARVING_SPEED_BYTES = 200 * 1024; // download below this (with demand) = starving
71
71
  const UPLOAD_CHOKED_WIRE_THRESHOLD = 2; // interested-but-choked wires implying reciprocity
72
72
  const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
73
+ /**
74
+ * How long a torrent counts as being in a hurry after it is added, and after
75
+ * the viewer moves to a part of the file that is not downloaded.
76
+ *
77
+ * BitTorrent gives data to peers that give data back: each peer re-ranks whom
78
+ * it serves roughly every 10 s and opens a handful of slots to whoever uploaded
79
+ * most to it, plus one chosen at random. Uploading almost nothing means waiting
80
+ * to be picked at random, one slot per cycle — which is exactly the ramp
81
+ * measured 2026-08-04 on a session with 96 peers already connected: 64 KB/s
82
+ * after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s, and the 16 MB the codec
83
+ * probe needs took 8.36 s of the 11.46 s before playback could start.
84
+ *
85
+ * The existing reciprocity boost could not help there: it only fires once the
86
+ * download has all but stopped (below 200 KB/s) with peers visibly choking us,
87
+ * and a ramp is neither. In the same session it first raised the limit 13.3 s
88
+ * after the torrent was added — after the wait it was supposed to shorten — and
89
+ * reached the generous rate at 43.7 s.
90
+ *
91
+ * So the two moments where a viewer is provably waiting get the generous rate
92
+ * outright, for two unchoke cycles, without waiting for evidence of failure.
93
+ */
94
+ const UPLOAD_HURRY_MS = 25_000;
73
95
 
74
96
  /**
75
97
  * Decide the client-wide upload limit (bytes/sec) from the torrents that
@@ -84,21 +106,66 @@ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
84
106
  * are choking us) → boost, to earn unchoke slots.
85
107
  * - Otherwise → floor (token upload, avoids an immediate choke without seeding).
86
108
  *
87
- * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, done?: boolean, name?: string }>} activeTorrents
88
- * @param {{ floor?: number, idleFloor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
109
+ * - Any active torrent in a HURRY just added, or the viewer has just moved
110
+ * somewhere the file is not downloaded boost for {@link UPLOAD_HURRY_MS},
111
+ * because that is when peers must be persuaded to serve us and there is no
112
+ * time to first prove that they are not.
113
+ *
114
+ * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, done?: boolean, name?: string, hurryUntil?: number }>} activeTorrents
115
+ * @param {{ floor?: number, idleFloor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number, now?: number }} [opts]
89
116
  * @returns {{ bytesPerSec: number, reason: string }}
90
117
  */
118
+ /**
119
+ * The torrents the upload policy is allowed to see.
120
+ *
121
+ * A torrent with a reader qualifies for the obvious reason. A torrent in a
122
+ * HURRY qualifies even without one, and that case is the important one: the
123
+ * first thing done with a new torrent is fetching the file's head and tail for
124
+ * the codec probe, and that read goes straight to `createReadStream` without
125
+ * registering a reader. Judged by readers alone the torrent looks unused for
126
+ * the whole of that wait — 8.36 s of the 11.46 s before playback in the session
127
+ * measured 2026-08-04 — so the upload stayed at the near-silent idle floor
128
+ * during the exact seconds peers were deciding whether to serve us.
129
+ *
130
+ * @param {Iterable<{ hurryUntil?: number }>} torrents
131
+ * @param {Map<object, { size: number }>} usageByTorrent - fileIndex sets, keyed by torrent.
132
+ * @param {number} now
133
+ * @returns {object[]}
134
+ */
135
+ export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
136
+ const chosen = [];
137
+ for (const torrent of torrents) {
138
+ const usage = usageByTorrent?.get?.(torrent);
139
+ if ((usage && usage.size > 0) || (torrent?.hurryUntil ?? 0) > now) {
140
+ chosen.push(torrent);
141
+ }
142
+ }
143
+ return chosen;
144
+ }
145
+
91
146
  export function decideUploadLimit(activeTorrents, opts = {}) {
92
147
  const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
93
148
  const idleFloor = opts.idleFloor ?? UPLOAD_IDLE_FLOOR_BYTES;
94
149
  const boost = opts.boost ?? UPLOAD_BOOST_BYTES;
95
150
  const starvingSpeed = opts.starvingSpeed ?? UPLOAD_STARVING_SPEED_BYTES;
96
151
  const chokedThreshold = opts.chokedThreshold ?? UPLOAD_CHOKED_WIRE_THRESHOLD;
152
+ const now = opts.now ?? Date.now();
97
153
 
98
154
  if (!Array.isArray(activeTorrents) || activeTorrents.length === 0) {
99
155
  return { bytesPerSec: idleFloor, reason: "idle: minimal keep-alive (0 blocks the swarm in wt3.x)" };
100
156
  }
101
157
 
158
+ for (const torrent of activeTorrents) {
159
+ const hurryUntil = typeof torrent?.hurryUntil === "number" ? torrent.hurryUntil : 0;
160
+ if (hurryUntil > now && torrent?.done !== true) {
161
+ const name = typeof torrent?.name === "string" ? torrent.name : "?";
162
+ return {
163
+ bytesPerSec: boost,
164
+ reason: `in a hurry — "${name}" needs data now (${Math.round((hurryUntil - now) / 1000)}s left)`
165
+ };
166
+ }
167
+ }
168
+
102
169
  for (const torrent of activeTorrents) {
103
170
  const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
104
171
  const chokedInterested = wires.filter(
@@ -395,14 +462,42 @@ export class TorrentPool {
395
462
  *
396
463
  * @returns {void}
397
464
  */
465
+ /**
466
+ * Note that a torrent needs data now, and act on it immediately.
467
+ *
468
+ * `hurryUntil` is read by {@link decideUploadLimit}; the re-evaluation is
469
+ * what makes it take effect at once, because the adjuster otherwise runs on a
470
+ * 5 s timer and the whole hurry is only 25 s long.
471
+ *
472
+ * @param {import("webtorrent").Torrent} torrent
473
+ * @param {string} why - For the log line, so the two causes are told apart.
474
+ * @returns {void}
475
+ */
476
+ #markHurry(torrent, why) {
477
+ if (!torrent) {
478
+ return;
479
+ }
480
+ const until = Date.now() + UPLOAD_HURRY_MS;
481
+ if ((torrent.hurryUntil ?? 0) >= until - 1_000) {
482
+ return;
483
+ }
484
+ torrent.hurryUntil = until;
485
+ logger.info(
486
+ `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] uploading generously for ` +
487
+ `${Math.round(UPLOAD_HURRY_MS / 1000)}s — ${why}`
488
+ );
489
+ this.#adjustUploadLimit();
490
+ }
491
+
398
492
  #adjustUploadLimit() {
399
493
  if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
400
494
  return;
401
495
  }
402
- const active = [...this.torrents.values()].filter((torrent) => {
403
- const usage = this.fileUsageByTorrent.get(torrent);
404
- return usage && usage.size > 0;
405
- });
496
+ const active = torrentsForUploadPolicy(
497
+ this.torrents.values(),
498
+ this.fileUsageByTorrent,
499
+ Date.now()
500
+ );
406
501
  const { bytesPerSec, reason } = decideUploadLimit(active);
407
502
  if (bytesPerSec === this.#uploadLimit) {
408
503
  return;
@@ -477,6 +572,9 @@ export class TorrentPool {
477
572
  * @returns {void}
478
573
  */
479
574
  #attachSwarmDiagnostics(label, torrent) {
575
+ // A torrent nobody has asked for yet does not exist: this is called the
576
+ // moment one is added, which is the moment a viewer started waiting.
577
+ this.#markHurry(torrent, "just added");
480
578
  const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
481
579
  logger.info(
482
580
  `torrent-pool: [${label}] added: files=${torrent.files?.length ?? 0} ` +
@@ -1084,6 +1182,10 @@ export class TorrentPool {
1084
1182
  const isJump =
1085
1183
  previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
1086
1184
  if (isJump) {
1185
+ // A jump is a seek. Whatever the swarm was giving us was for somewhere
1186
+ // else, and the pieces at the new position have to be earned from peers
1187
+ // that are choking us — the same standing start as a fresh torrent.
1188
+ this.#markHurry(torrent, "the viewer moved");
1087
1189
  const percent = ((safeStart / fileLength) * 100).toFixed(1);
1088
1190
  logger.info(
1089
1191
  `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file Uploading generously at the two moments a viewer is waiting.
3
+ *
4
+ * BitTorrent peers serve those who serve them: each re-ranks its takers about
5
+ * every 10 s and opens a few slots to whoever uploaded most, plus one at
6
+ * random. Uploading a token 8-50 KB/s means being picked at random, one slot
7
+ * per cycle. Measured 2026-08-04 on a session with 96 peers already connected:
8
+ * 64 KB/s after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s — and the 16 MB the
9
+ * codec probe needs took 8.36 s of the 11.46 s before playback began.
10
+ *
11
+ * The reciprocity boost could not help: it waits for the download to be all but
12
+ * dead (below 200 KB/s) with peers visibly choking us, which a ramp is not. In
13
+ * that session it first moved the limit 13.3 s after the torrent was added, and
14
+ * reached the generous rate at 43.7 s — both after the wait they were meant to
15
+ * shorten.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { decideUploadLimit, torrentsForUploadPolicy } from "../services/torrent-pool.js";
21
+
22
+ const NOW = 1_000_000;
23
+ const healthy = (extra = {}) => ({
24
+ name: "film.mkv",
25
+ wires: [{ amInterested: true, peerChoking: false }],
26
+ downloadSpeed: 5 * 1024 * 1024,
27
+ done: false,
28
+ ...extra
29
+ });
30
+
31
+ test("a torrent in a hurry gets the generous rate even while downloading well", () => {
32
+ const decision = decideUploadLimit([healthy({ hurryUntil: NOW + 10_000 })], { now: NOW });
33
+ assert.equal(decision.bytesPerSec, 512 * 1024);
34
+ assert.match(decision.reason, /in a hurry/);
35
+ });
36
+
37
+ test("the hurry expires on its own", () => {
38
+ const decision = decideUploadLimit([healthy({ hurryUntil: NOW - 1 })], { now: NOW });
39
+ assert.equal(decision.bytesPerSec, 50 * 1024, "back to the token upload once the rush is over");
40
+ });
41
+
42
+ test("a finished torrent is never in a hurry", () => {
43
+ // Nothing left to download, so there is nothing to buy with the upload — and
44
+ // seeding is what we deliberately avoid.
45
+ const decision = decideUploadLimit([healthy({ hurryUntil: NOW + 10_000, done: true })], { now: NOW });
46
+ assert.equal(decision.bytesPerSec, 50 * 1024);
47
+ });
48
+
49
+ test("nothing being watched still means near-silence", () => {
50
+ assert.equal(decideUploadLimit([], { now: NOW }).bytesPerSec, 8 * 1024);
51
+ });
52
+
53
+ test("the reciprocity boost still works when no hurry is on", () => {
54
+ const starving = {
55
+ name: "film.mkv",
56
+ wires: [
57
+ { amInterested: true, peerChoking: true },
58
+ { amInterested: true, peerChoking: true }
59
+ ],
60
+ downloadSpeed: 10 * 1024,
61
+ done: false
62
+ };
63
+ const decision = decideUploadLimit([starving], { now: NOW });
64
+ assert.equal(decision.bytesPerSec, 512 * 1024);
65
+ assert.match(decision.reason, /earn unchoke/);
66
+ });
67
+
68
+ test("a torrent with no reader still reaches the policy while it is in a hurry", () => {
69
+ // The moment that matters: a torrent has just been added and its head and
70
+ // tail are being fetched for the codec probe. That read goes straight to
71
+ // `createReadStream`, so no reader is registered — and the selection only
72
+ // ever kept torrents that had one, which is where the gap was.
73
+ const hurrying = { name: "film.mkv", hurryUntil: NOW + 20_000 };
74
+ const idle = { name: "other.mkv" };
75
+ const usage = new Map();
76
+
77
+ assert.deepEqual(
78
+ torrentsForUploadPolicy([hurrying, idle], usage, NOW),
79
+ [hurrying],
80
+ "a torrent with no reader yet was ignored"
81
+ );
82
+
83
+ assert.deepEqual(
84
+ torrentsForUploadPolicy([{ ...hurrying, hurryUntil: NOW - 1 }, idle], usage, NOW),
85
+ [],
86
+ "and stops counting once the rush is over"
87
+ );
88
+
89
+ const read = { name: "watched.mkv" };
90
+ usage.set(read, new Set([0]));
91
+ assert.deepEqual(
92
+ torrentsForUploadPolicy([read], usage, NOW),
93
+ [read],
94
+ "a torrent being read still counts, hurry or not"
95
+ );
96
+ });