@torrent-tv/proxy 2.50.0 → 2.52.0
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 +20 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +40 -4
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -0
- package/server.js +4 -0
- package/services/hls-session-manager.js +298 -22
- package/services/torrent-pool.js +440 -22
- package/services/torrent-worker/worker.js +9 -2
- package/test/dht-bootstrap.test.js +72 -0
- package/test/run-position-follows-published-grid.test.js +87 -0
- package/test/swarm-reach.test.js +125 -0
- package/test/viewer-position-on-open.test.js +53 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The DHT's entry points are given as addresses, not names.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-21 on the addon host. Two of the three bootstrap nodes the
|
|
5
|
+
* library ships answer nothing: `router.bittorrent.com` and `router.utorrent.com`
|
|
6
|
+
* did not reply to a hand-written `ping` at all, while a control datagram to a
|
|
7
|
+
* DNS server came back in 20 ms. The third, `dht.transmissionbt.com`, is alive —
|
|
8
|
+
* it answered `find_node` with eight nodes — but on a host with global IPv6 its
|
|
9
|
+
* name resolves to an IPv6 address first, and the DHT's socket is IPv4, so by
|
|
10
|
+
* name it was never reached. By name: 0 nodes after 21 s, every run. By address:
|
|
11
|
+
* 22 nodes in 5 s.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
|
|
17
|
+
import { dhtNodeCount, parseBootstrapEntry, resolveDhtBootstrap } from "../services/torrent-pool.js";
|
|
18
|
+
|
|
19
|
+
test("a bootstrap entry is split into host and port", () => {
|
|
20
|
+
assert.deepEqual(parseBootstrapEntry("dht.libtorrent.org:25401"), {
|
|
21
|
+
host: "dht.libtorrent.org",
|
|
22
|
+
port: 25401
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an entry with no port gets the DHT's own", () => {
|
|
27
|
+
assert.deepEqual(parseBootstrapEntry("router.bittorrent.com"), {
|
|
28
|
+
host: "router.bittorrent.com",
|
|
29
|
+
port: 6881
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an unusable entry is dropped rather than breaking the client", () => {
|
|
34
|
+
// One bad line in the list must cost that node and nothing else — the DHT is
|
|
35
|
+
// best-effort by nature and a typo here would otherwise take the whole client
|
|
36
|
+
// down at construction.
|
|
37
|
+
assert.equal(parseBootstrapEntry(""), null);
|
|
38
|
+
assert.equal(parseBootstrapEntry(" "), null);
|
|
39
|
+
assert.equal(parseBootstrapEntry("host:0"), null);
|
|
40
|
+
assert.equal(parseBootstrapEntry("host:70000"), null);
|
|
41
|
+
assert.equal(parseBootstrapEntry("host:not-a-port"), null);
|
|
42
|
+
assert.equal(parseBootstrapEntry(":6881"), null);
|
|
43
|
+
assert.equal(parseBootstrapEntry(null), null);
|
|
44
|
+
assert.equal(parseBootstrapEntry(42), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the routing table's size is readable, and its absence is not an error", () => {
|
|
48
|
+
assert.equal(dhtNodeCount({ dht: { nodes: { toArray: () => [1, 2, 3] } } }), 3);
|
|
49
|
+
assert.equal(dhtNodeCount({ dht: { nodes: { toArray: () => [] } } }), 0);
|
|
50
|
+
// A client built without a DHT, or one whose internals moved: the difference
|
|
51
|
+
// between "no DHT" and "an empty DHT" is the whole point of the report, so
|
|
52
|
+
// the first must not be printed as the second.
|
|
53
|
+
assert.equal(dhtNodeCount({}), null);
|
|
54
|
+
assert.equal(dhtNodeCount({ dht: {} }), null);
|
|
55
|
+
assert.equal(dhtNodeCount(null), null);
|
|
56
|
+
assert.equal(dhtNodeCount({ dht: { nodes: { toArray: () => { throw new Error("gone"); } } } }), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a name that will not resolve inside the cap is dropped, not waited on", async () => {
|
|
60
|
+
// The whole call is awaited before the torrent client exists, so a resolver
|
|
61
|
+
// that black-holes must cost the cap and not c-ares' own four tries.
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
// `.invalid` is reserved by RFC 2606 and never resolves; the cap is what
|
|
64
|
+
// bounds the wait when a resolver answers slowly rather than quickly.
|
|
65
|
+
const resolved = await resolveDhtBootstrap(["nothing.invalid:6881"], 200);
|
|
66
|
+
assert.deepEqual(resolved, []);
|
|
67
|
+
assert.ok(Date.now() - started < 2000, "the cap, not the resolver, decided when to give up");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an empty list is an answer, not a failure", async () => {
|
|
71
|
+
assert.deepEqual(await resolveDhtBootstrap([], 200), []);
|
|
72
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A run is POSITIONED where the player was told the segment begins.
|
|
3
|
+
*
|
|
4
|
+
* The companion of `cuts-follow-published-grid`, and the half that was missing.
|
|
5
|
+
* 2.45.0 moved the cut list onto the published table and left the position on
|
|
6
|
+
* the live one — but `-segment_times` are measured from wherever the run really
|
|
7
|
+
* began, so any distance between the two moves EVERY cut of that run by it. The
|
|
8
|
+
* live table keeps being corrected, and the corrections run backwards, so each
|
|
9
|
+
* restart began a little earlier than the grid its cuts were stated on and the
|
|
10
|
+
* distance accumulated across restarts.
|
|
11
|
+
*
|
|
12
|
+
* Field 2026-08-21, `JUFD665.mp4` (MP4, copy path, index read cleanly): after
|
|
13
|
+
* one seek restart a produced segment held the boundary two places before its
|
|
14
|
+
* own number — 16.684 s, which is 2.0000 segments — and after the next restart,
|
|
15
|
+
* four places, 33.5 s. The player's buffer then stopped extending at all,
|
|
16
|
+
* because every fragment's content landed before the time its playlist entry
|
|
17
|
+
* named: `bufferEnd` stood still at 4571.1 s through four `frag-far` warnings
|
|
18
|
+
* until hls.js gave up and jumped the viewer 16.8 s forward.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
|
|
24
|
+
import { HlsSessionManager, describeGridDrift, segmentCutTimesFrom } from "../services/hls-session-manager.js";
|
|
25
|
+
|
|
26
|
+
/** What the playlist in the player's hands says. */
|
|
27
|
+
const PUBLISHED = [0, 8.342, 16.684, 25.026, 33.368, 41.71];
|
|
28
|
+
/** The same grid after produced segments moved two of its cuts backwards. */
|
|
29
|
+
const CORRECTED = [0, 8.342, 14.682, 25.026, 31.366, 41.71];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {{ manager: HlsSessionManager, session: object }}
|
|
33
|
+
*/
|
|
34
|
+
function sessionWithDriftedGrid() {
|
|
35
|
+
const manager = new HlsSessionManager({
|
|
36
|
+
enabled: true,
|
|
37
|
+
ffmpegBin: "ffmpeg",
|
|
38
|
+
localBindHost: "127.0.0.1",
|
|
39
|
+
localPort: 9090
|
|
40
|
+
});
|
|
41
|
+
const session = {
|
|
42
|
+
id: "picture",
|
|
43
|
+
segmentBoundaries: [...CORRECTED],
|
|
44
|
+
publishedBoundaries: [...PUBLISHED]
|
|
45
|
+
};
|
|
46
|
+
return { manager, session };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("a run starts at the time the player was told, not at the corrected one", () => {
|
|
50
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
51
|
+
assert.equal(manager.runStartTimeFor(session, 2), 16.684);
|
|
52
|
+
assert.notEqual(manager.runStartTimeFor(session, 2), session.segmentBoundaries[2]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("position and cut list come from the same table", () => {
|
|
56
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
57
|
+
const grid = manager.publishedGridFor(session);
|
|
58
|
+
const start = manager.runStartTimeFor(session, 2);
|
|
59
|
+
// The cut list is stated as offsets from where the run begins. Adding the
|
|
60
|
+
// position back must land on the published boundaries exactly — which is the
|
|
61
|
+
// property that was false while the two came from different tables.
|
|
62
|
+
const absolute = segmentCutTimesFrom(grid, 2).map((offset) => Number((start + offset).toFixed(3)));
|
|
63
|
+
assert.deepEqual(absolute, [25.026, 33.368]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a session that published no grid positions on the live one", () => {
|
|
67
|
+
const { manager } = sessionWithDriftedGrid();
|
|
68
|
+
const session = { id: "no-playlist", segmentBoundaries: [...CORRECTED], publishedBoundaries: [] };
|
|
69
|
+
assert.equal(manager.runStartTimeFor(session, 2), CORRECTED[2]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an index beyond the table is clamped rather than returning nothing", () => {
|
|
73
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
74
|
+
assert.equal(manager.runStartTimeFor(session, 9999), PUBLISHED[PUBLISHED.length - 1]);
|
|
75
|
+
assert.equal(manager.runStartTimeFor(session, -3), PUBLISHED[0]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the drift between the two tables is stated in full", () => {
|
|
79
|
+
assert.equal(describeGridDrift(PUBLISHED, PUBLISHED), "identical");
|
|
80
|
+
assert.equal(
|
|
81
|
+
describeGridDrift(PUBLISHED, CORRECTED),
|
|
82
|
+
"2 of 6 boundaries apart, worst 2.002s at #2"
|
|
83
|
+
);
|
|
84
|
+
assert.equal(describeGridDrift(PUBLISHED, [0, 1]), "a different length (6 against 2)");
|
|
85
|
+
assert.equal(describeGridDrift(null, CORRECTED), "not comparable");
|
|
86
|
+
assert.equal(describeGridDrift([], []), "not comparable");
|
|
87
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The line describing a swarm answers the question it is asked.
|
|
3
|
+
*
|
|
4
|
+
* It could not. `routes/api/sources/stats/get.js` printed `peers=N` beside
|
|
5
|
+
* `wires=?`, which reads as two quantities of which one is unknown — while
|
|
6
|
+
* WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js:254`, the same in
|
|
7
|
+
* 2.8.5 and 3.0.21), so the first was the connection count and the second was a
|
|
8
|
+
* field that has never printed anything, because the torrent lives on a worker
|
|
9
|
+
* thread where that property does not exist.
|
|
10
|
+
*
|
|
11
|
+
* What was missing is the other half: how many peers the client HOLDS but is
|
|
12
|
+
* not connected to, and what the tracker said the swarm has. Measured
|
|
13
|
+
* 2026-08-21 on `JUFD665.mp4`, the tracker answered `seeders=5` at 13:40:30 and
|
|
14
|
+
* the first wire arrived at 13:44:47 — 4 min 17 s in which the stats line
|
|
15
|
+
* repeated unchanged every two seconds while the answer ("offered, not
|
|
16
|
+
* connected") was already in the process.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
|
|
22
|
+
import { bestAnnounce, describeSwarmReach, secondsToFirstPeer } from "../services/torrent-pool.js";
|
|
23
|
+
|
|
24
|
+
test("connected and known are separate numbers", () => {
|
|
25
|
+
const torrent = { wires: [{}, {}], _peersLength: 7, _numQueued: 4 };
|
|
26
|
+
assert.deepEqual(describeSwarmReach(torrent), {
|
|
27
|
+
connectedPeers: 2,
|
|
28
|
+
knownPeers: 7,
|
|
29
|
+
queuedPeers: 4
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("offered but not connected is distinguishable from nobody offered", () => {
|
|
34
|
+
// The two shapes the field produced. They need opposite investigations, and
|
|
35
|
+
// one line has to tell them apart.
|
|
36
|
+
const offeredNotConnected = describeSwarmReach({ wires: [], _peersLength: 5, _numQueued: 5 });
|
|
37
|
+
const nobodyOffered = describeSwarmReach({ wires: [], _peersLength: 0, _numQueued: 0 });
|
|
38
|
+
assert.equal(offeredNotConnected.connectedPeers, 0);
|
|
39
|
+
assert.equal(nobodyOffered.connectedPeers, 0);
|
|
40
|
+
assert.notEqual(offeredNotConnected.knownPeers, nobodyOffered.knownPeers);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("internals that are gone say nothing rather than breaking the poll", () => {
|
|
44
|
+
// `_peersLength` and `_numQueued` are not WebTorrent's published interface.
|
|
45
|
+
// The browser polls this every two seconds, so a version that drops them must
|
|
46
|
+
// cost the field and not the answer.
|
|
47
|
+
assert.deepEqual(describeSwarmReach({ wires: [{}] }), {
|
|
48
|
+
connectedPeers: 1,
|
|
49
|
+
knownPeers: null,
|
|
50
|
+
queuedPeers: null
|
|
51
|
+
});
|
|
52
|
+
assert.deepEqual(describeSwarmReach({}), {
|
|
53
|
+
connectedPeers: 0,
|
|
54
|
+
knownPeers: null,
|
|
55
|
+
queuedPeers: null
|
|
56
|
+
});
|
|
57
|
+
assert.deepEqual(describeSwarmReach(null), {
|
|
58
|
+
connectedPeers: 0,
|
|
59
|
+
knownPeers: null,
|
|
60
|
+
queuedPeers: null
|
|
61
|
+
});
|
|
62
|
+
const throwing = {
|
|
63
|
+
wires: [],
|
|
64
|
+
get _peersLength() {
|
|
65
|
+
throw new Error("destroyed");
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
assert.deepEqual(describeSwarmReach(throwing), {
|
|
69
|
+
connectedPeers: 0,
|
|
70
|
+
knownPeers: null,
|
|
71
|
+
queuedPeers: null
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("the wait for a first peer is a measured quantity", () => {
|
|
76
|
+
// The field case: added 13:40:30.357, first wire 13:44:47.123.
|
|
77
|
+
assert.equal(secondsToFirstPeer(1000, 258_766), 257.766);
|
|
78
|
+
assert.equal(secondsToFirstPeer(1000, 1000), 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("no peer yet is not a duration", () => {
|
|
82
|
+
assert.equal(secondsToFirstPeer(1000, null), null);
|
|
83
|
+
assert.equal(secondsToFirstPeer(null, 2000), null);
|
|
84
|
+
// A clock that went backwards is not a negative wait; it is no reading.
|
|
85
|
+
assert.equal(secondsToFirstPeer(2000, 1000), null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the best tracker answer wins, not the most recent", () => {
|
|
89
|
+
// A live tracker says 500, a dead one answers 0 two seconds later. Keeping
|
|
90
|
+
// the last would print "nobody offered" about a swarm of five hundred, which
|
|
91
|
+
// inverts the one distinction this figure is carried for.
|
|
92
|
+
const answers = [
|
|
93
|
+
{ seeders: 500, leechers: 40 },
|
|
94
|
+
{ seeders: 0, leechers: 0 }
|
|
95
|
+
];
|
|
96
|
+
assert.deepEqual(bestAnnounce(answers), { seeders: 500, leechers: 40, trackers: 2 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("trackers that answered are counted even when none knew anything", () => {
|
|
100
|
+
assert.deepEqual(bestAnnounce([{ seeders: null, leechers: null }]), {
|
|
101
|
+
seeders: null,
|
|
102
|
+
leechers: null,
|
|
103
|
+
trackers: 1
|
|
104
|
+
});
|
|
105
|
+
// No tracker has answered at all — a different state from "answered, knows
|
|
106
|
+
// nobody", and the line says so.
|
|
107
|
+
assert.deepEqual(bestAnnounce([]), { seeders: null, leechers: null, trackers: 0 });
|
|
108
|
+
assert.deepEqual(bestAnnounce(null), { seeders: null, leechers: null, trackers: 0 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("leechers travel with the seeder count they were reported beside", () => {
|
|
112
|
+
const answers = [
|
|
113
|
+
{ seeders: 2, leechers: 99 },
|
|
114
|
+
{ seeders: 7, leechers: 3 }
|
|
115
|
+
];
|
|
116
|
+
assert.equal(bestAnnounce(answers).leechers, 3);
|
|
117
|
+
// Including when the winning answer gave no leecher count: carrying the
|
|
118
|
+
// previous tracker's figure forward would present two trackers' numbers as
|
|
119
|
+
// one reading.
|
|
120
|
+
assert.deepEqual(bestAnnounce([{ seeders: 2, leechers: 99 }, { seeders: 7, leechers: null }]), {
|
|
121
|
+
seeders: 7,
|
|
122
|
+
leechers: null,
|
|
123
|
+
trackers: 2
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Opening a file at a position puts the SOUND there too.
|
|
3
|
+
*
|
|
4
|
+
* The audio rendition is a session of its own, and where it starts is computed
|
|
5
|
+
* from where the viewer is. That reading had three sources and only two were
|
|
6
|
+
* consulted — a position seeked to, and the last segment this session served —
|
|
7
|
+
* both of which are written by things that have not happened yet at the moment
|
|
8
|
+
* a file is opened at a position. So the answer was zero.
|
|
9
|
+
*
|
|
10
|
+
* Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address
|
|
11
|
+
* bar at 52:07:
|
|
12
|
+
*
|
|
13
|
+
* 18:02:55.124 8ed85605 start=3130s -ss 3125.25 -an -map 0:v:0 -c:v copy
|
|
14
|
+
* 18:02:55.632 33b0b046 start=0s no -ss -vn -map 0:a:0 -c:a aac
|
|
15
|
+
*
|
|
16
|
+
* The picture went to segment #781, the sound to #0. The player asked both for
|
|
17
|
+
* #782; the picture had it, the sound re-encoded 57.5 s of a 3130 s film in the
|
|
18
|
+
* 45 s the request lasted and then answered 404 — shown to the viewer as "the
|
|
19
|
+
* proxy accepted the request but sent no video".
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import test from "node:test";
|
|
24
|
+
|
|
25
|
+
import { resolveViewerPosition } from "../services/hls-session-manager.js";
|
|
26
|
+
|
|
27
|
+
test("a file opened at a position has its viewer at that position", () => {
|
|
28
|
+
// Nothing has been seeked and nothing served yet — the state at the instant
|
|
29
|
+
// the audio rendition is created.
|
|
30
|
+
assert.equal(resolveViewerPosition({ openedAt: 3130 }), 3130);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a seek beats everything else", () => {
|
|
34
|
+
assert.equal(
|
|
35
|
+
resolveViewerPosition({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }),
|
|
36
|
+
900
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("what has been served beats where the file was opened", () => {
|
|
41
|
+
// The opening position is the oldest of the three readings: once a segment
|
|
42
|
+
// has been served, that is where the reading is.
|
|
43
|
+
assert.equal(resolveViewerPosition({ lastRequestedStart: 400, openedAt: 3130 }), 400);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("with nothing to go on the answer is the beginning", () => {
|
|
47
|
+
assert.equal(resolveViewerPosition({}), 0);
|
|
48
|
+
assert.equal(resolveViewerPosition({ seeked: 0, lastRequestedStart: 0, openedAt: 0 }), 0);
|
|
49
|
+
// Values that are not readings must not become one.
|
|
50
|
+
assert.equal(resolveViewerPosition({ seeked: Number.NaN, openedAt: Number.NaN }), 0);
|
|
51
|
+
assert.equal(resolveViewerPosition({ seeked: -5, openedAt: -5 }), 0);
|
|
52
|
+
assert.equal(resolveViewerPosition({ lastRequestedStart: null, openedAt: undefined }), 0);
|
|
53
|
+
});
|