@torrent-tv/proxy 2.9.95 → 2.9.96

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,8 @@
1
+ ## 2.9.96
2
+
3
+ - **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.
4
+ - **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.
5
+
1
6
  ## 2.9.95
2
7
 
3
8
  - **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.96",
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,8 +106,13 @@ 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
  */
91
118
  export function decideUploadLimit(activeTorrents, opts = {}) {
@@ -94,11 +121,23 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
94
121
  const boost = opts.boost ?? UPLOAD_BOOST_BYTES;
95
122
  const starvingSpeed = opts.starvingSpeed ?? UPLOAD_STARVING_SPEED_BYTES;
96
123
  const chokedThreshold = opts.chokedThreshold ?? UPLOAD_CHOKED_WIRE_THRESHOLD;
124
+ const now = opts.now ?? Date.now();
97
125
 
98
126
  if (!Array.isArray(activeTorrents) || activeTorrents.length === 0) {
99
127
  return { bytesPerSec: idleFloor, reason: "idle: minimal keep-alive (0 blocks the swarm in wt3.x)" };
100
128
  }
101
129
 
130
+ for (const torrent of activeTorrents) {
131
+ const hurryUntil = typeof torrent?.hurryUntil === "number" ? torrent.hurryUntil : 0;
132
+ if (hurryUntil > now && torrent?.done !== true) {
133
+ const name = typeof torrent?.name === "string" ? torrent.name : "?";
134
+ return {
135
+ bytesPerSec: boost,
136
+ reason: `in a hurry — "${name}" needs data now (${Math.round((hurryUntil - now) / 1000)}s left)`
137
+ };
138
+ }
139
+ }
140
+
102
141
  for (const torrent of activeTorrents) {
103
142
  const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
104
143
  const chokedInterested = wires.filter(
@@ -395,6 +434,33 @@ export class TorrentPool {
395
434
  *
396
435
  * @returns {void}
397
436
  */
437
+ /**
438
+ * Note that a torrent needs data now, and act on it immediately.
439
+ *
440
+ * `hurryUntil` is read by {@link decideUploadLimit}; the re-evaluation is
441
+ * what makes it take effect at once, because the adjuster otherwise runs on a
442
+ * 5 s timer and the whole hurry is only 25 s long.
443
+ *
444
+ * @param {import("webtorrent").Torrent} torrent
445
+ * @param {string} why - For the log line, so the two causes are told apart.
446
+ * @returns {void}
447
+ */
448
+ #markHurry(torrent, why) {
449
+ if (!torrent) {
450
+ return;
451
+ }
452
+ const until = Date.now() + UPLOAD_HURRY_MS;
453
+ if ((torrent.hurryUntil ?? 0) >= until - 1_000) {
454
+ return;
455
+ }
456
+ torrent.hurryUntil = until;
457
+ logger.info(
458
+ `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] uploading generously for ` +
459
+ `${Math.round(UPLOAD_HURRY_MS / 1000)}s — ${why}`
460
+ );
461
+ this.#adjustUploadLimit();
462
+ }
463
+
398
464
  #adjustUploadLimit() {
399
465
  if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
400
466
  return;
@@ -477,6 +543,9 @@ export class TorrentPool {
477
543
  * @returns {void}
478
544
  */
479
545
  #attachSwarmDiagnostics(label, torrent) {
546
+ // A torrent nobody has asked for yet does not exist: this is called the
547
+ // moment one is added, which is the moment a viewer started waiting.
548
+ this.#markHurry(torrent, "just added");
480
549
  const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
481
550
  logger.info(
482
551
  `torrent-pool: [${label}] added: files=${torrent.files?.length ?? 0} ` +
@@ -1084,6 +1153,10 @@ export class TorrentPool {
1084
1153
  const isJump =
1085
1154
  previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
1086
1155
  if (isJump) {
1156
+ // A jump is a seek. Whatever the swarm was giving us was for somewhere
1157
+ // else, and the pieces at the new position have to be earned from peers
1158
+ // that are choking us — the same standing start as a fresh torrent.
1159
+ this.#markHurry(torrent, "the viewer moved");
1087
1160
  const percent = ((safeStart / fileLength) * 100).toFixed(1);
1088
1161
  logger.info(
1089
1162
  `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
@@ -0,0 +1,66 @@
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 } 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
+ });