@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 +9 -0
- package/package.json +2 -3
- package/services/hls-session-manager.js +9 -0
- package/services/torrent-pool.js +108 -6
- package/test/upload-hurry.test.js +96 -0
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.
|
|
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"]
|
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
*
|
|
88
|
-
*
|
|
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 =
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
});
|