@torrent-tv/proxy 2.55.0 → 2.55.1
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 +5 -0
- package/package.json +2 -1
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -0
- package/services/hwaccel.js +38 -7
- package/services/torrent-pool.js +8 -4
- package/services/torrent-source-key.js +38 -0
- package/services/torrent-worker/pool-adapter.js +2 -16
- package/test/decode-measurement.test.js +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 2.55.1
|
|
2
|
+
|
|
3
|
+
- **Fix**: A source is now keyed by its own infohash, not by a hash of the request bytes. A magnet URI and a `.torrent` file for the same content are different bytes, so the old key (`sha1` of the source) named the same film as two unrelated sources — measured 2026-08-19, `a518ff46…` and `7ab2fb5d…` for one infohash `11f09299…` — sharing neither the swarm, nor a cache, nor any work already downloaded, and surfacing as `WebTorrent client error: Cannot add duplicate torrent`. The new key (`services/torrent-source-key.js`) reads the infohash straight out of the magnet's `btih` or the `.torrent`'s own `info` dictionary via `parse-torrent`, synchronously, with no network round trip — so both forms of the same torrent now share one entry from the first request, on both the pool's own map (`torrent-pool.js`) and the worker-thread boundary (`torrent-worker/pool-adapter.js`), instead of relying on WebTorrent's own duplicate-add error to reconcile them after the fact.
|
|
4
|
+
- **Chore**: The decode-cost reading now logs how many MB/s the pipe was actually fed against how many MB/s the measured speed implies were needed (item 4(d2)) — a divergence between the two is worth a second look before trusting the reading. A `write()`-return-value signal was tried first, to say outright whether the pipe or the decoder was the limit, and measured false on every reading taken while building it — including clips this host decodes at 15-80x with slack to spare — so it does not discriminate and was not shipped; only the byte count, which is real, is kept.
|
|
5
|
+
|
|
1
6
|
## 2.55.0
|
|
2
7
|
|
|
3
8
|
- **Fix**: The decode calibration was measuring the loop rather than the decode. It looped each clip with `-stream_loop -1`, and a loop is not free: measured 2026-08-22, a restart costs **0.03 s on the 480p clip and 0.12 s on the 1080p one** — it scales with the picture, so it is the decoder tearing down and re-allocating its frame buffers rather than anything about reading the file. A five-second clip decoded at 55x restarts eleven times a second, and that cost dominated the reading: the same clips measured 53.7x looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse, the bias depends on BOTH the clip's own resolution and the host's speed — the two axes the fit exists to separate — so it did not cancel out, it tilted the fit. That is the fast-host failure recorded on 2026-08-20, where a desktop read 1080p at 9.35 Mbit/s as cheaper than 720p at 9.94, an ordering no decoder produces, and the H.264 fit refused to solve at all. A host that could not fit H.264 got no decode figure whatsoever, which is exactly the host most able to serve.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.55.
|
|
3
|
+
"version": "2.55.1",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"franc": "^6.2.0",
|
|
36
36
|
"get-port": "^7.1.0",
|
|
37
37
|
"node-datachannel": "^0.32.0",
|
|
38
|
+
"parse-torrent": "^11.0.23",
|
|
38
39
|
"uint8-util": "2.2.6",
|
|
39
40
|
"webtorrent": "2.8.5",
|
|
40
41
|
"werift": "^0.24.2",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Does the preset benchmark's pixels/sec figure hold at a different size? (2026-08-22)
|
|
2
|
+
|
|
3
|
+
Roadmap item 4(e): the software-preset benchmark (`benchmarkSoftwarePresets`,
|
|
4
|
+
`services/hwaccel.js`) measures encoder throughput at one fixed reference size —
|
|
5
|
+
640×360 — and every later use of that figure (deciding which quality rungs this
|
|
6
|
+
host can sustain) scales it by pixel count, assuming cost per pixel is constant
|
|
7
|
+
regardless of frame size. This was never checked against a second size.
|
|
8
|
+
|
|
9
|
+
## Measurement
|
|
10
|
+
|
|
11
|
+
Same clip (`cal-h264-1080-hi.mp4`), same `libx264` presets, same measurement
|
|
12
|
+
method as the shipped benchmark (slope between two `-progress` reports,
|
|
13
|
+
excluding startup) — run once at the existing 640×360 reference and once at
|
|
14
|
+
1280×720 (4× the pixel count), on the developer's desktop:
|
|
15
|
+
|
|
16
|
+
| preset | 640×360 | 1280×720 | ratio (should be ~1.0) |
|
|
17
|
+
|-----------|----------------|----------------|-------------------------|
|
|
18
|
+
| ultrafast | 1322.8 Mpx/s | 588.6 Mpx/s | 0.445 |
|
|
19
|
+
| fast | 176.6 Mpx/s | 128.1 Mpx/s | 0.725 |
|
|
20
|
+
|
|
21
|
+
Script: ad-hoc, not committed (reused the benchmark's own ffmpeg arguments at a
|
|
22
|
+
second `-vf scale=`/`-s` size; available on request if this needs re-running).
|
|
23
|
+
|
|
24
|
+
## Reading
|
|
25
|
+
|
|
26
|
+
The assumption does not hold. Cost per pixel RISES with frame size on this
|
|
27
|
+
host — by 2.25× for `ultrafast`, 1.38× for `fast`, at 4× the pixel count — and
|
|
28
|
+
the effect is preset-dependent, not a single constant. A quality ladder priced
|
|
29
|
+
from the 640×360 figure alone over-states what this host can sustain at 1080p,
|
|
30
|
+
worse for the faster presets that the ladder falls back to under load.
|
|
31
|
+
|
|
32
|
+
## Why this does not become a code change yet
|
|
33
|
+
|
|
34
|
+
One host, one clip, two sizes. [[feedback_pool_diversity]] — a correction fitted
|
|
35
|
+
to this desktop and applied to every proxy in the pool would be exactly the
|
|
36
|
+
mistake that memory exists to prevent: the pool spans ARM boards (HA Yellow /
|
|
37
|
+
CM4) with different cache sizes and memory bandwidth, where a size penalty of
|
|
38
|
+
this shape could be smaller, larger, or absent. [[feedback_no_fudged_numbers]] —
|
|
39
|
+
publishing 0.445/0.725 as universal constants is fabricating a number for hosts
|
|
40
|
+
that were never measured.
|
|
41
|
+
|
|
42
|
+
The benchmark already measures per-host (item 4(f), 4(h)); a size-scaling
|
|
43
|
+
correction belongs there too — the benchmark itself gains a second, cheap
|
|
44
|
+
measurement point (one preset, one second size) and derives its own exponent
|
|
45
|
+
from that host's two readings, the same way `fitDecodeCost` derives its terms
|
|
46
|
+
from clips rather than from a stated formula. That is the remaining work under
|
|
47
|
+
item 4(e); this note is the measurement that justifies doing it, not the fix
|
|
48
|
+
itself.
|
|
49
|
+
|
|
50
|
+
## What is verified
|
|
51
|
+
|
|
52
|
+
The measurement method matches the shipped benchmark exactly (real footage,
|
|
53
|
+
raw frames decoded once, slope between two progress reports) — the only
|
|
54
|
+
variable changed was the target size, so this is not comparing two different
|
|
55
|
+
methodologies.
|
package/services/hwaccel.js
CHANGED
|
@@ -865,7 +865,7 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
865
865
|
log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
|
|
866
866
|
return null;
|
|
867
867
|
}
|
|
868
|
-
const alone = await decodePipedStream(ffmpegBin, stream);
|
|
868
|
+
const alone = await decodePipedStream(ffmpegBin, stream, log);
|
|
869
869
|
if (!alone?.speed) {
|
|
870
870
|
log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
|
|
871
871
|
return null;
|
|
@@ -892,7 +892,7 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
892
892
|
await new Promise((resolve) => {
|
|
893
893
|
setTimeout(resolve, 2_000);
|
|
894
894
|
});
|
|
895
|
-
const withCompany = await decodePipedStream(ffmpegBin, stream);
|
|
895
|
+
const withCompany = await decodePipedStream(ffmpegBin, stream, log);
|
|
896
896
|
if (withCompany?.speed) {
|
|
897
897
|
beside.push({ others, speed: withCompany.speed });
|
|
898
898
|
}
|
|
@@ -985,7 +985,7 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
|
985
985
|
}
|
|
986
986
|
for (const [index, clip] of clips.entries()) {
|
|
987
987
|
const stream = streams[index];
|
|
988
|
-
const measured = stream ? await decodePipedStream(ffmpegBin, stream) : null;
|
|
988
|
+
const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
|
|
989
989
|
if (!measured?.speed) {
|
|
990
990
|
log.warn(
|
|
991
991
|
`hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
|
|
@@ -1276,7 +1276,7 @@ export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
|
|
|
1276
1276
|
* @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
|
|
1277
1277
|
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
1278
1278
|
*/
|
|
1279
|
-
function decodePipedStream(ffmpegBin, stream) {
|
|
1279
|
+
function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
|
|
1280
1280
|
return new Promise((resolve) => {
|
|
1281
1281
|
const args = [
|
|
1282
1282
|
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
@@ -1298,6 +1298,18 @@ function decodePipedStream(ffmpegBin, stream) {
|
|
|
1298
1298
|
let settled = false;
|
|
1299
1299
|
let child;
|
|
1300
1300
|
const startedAt = Date.now();
|
|
1301
|
+
// Whether the FEED, not the decoder, could be what this reading measures
|
|
1302
|
+
// (item 4(d2)). `child.stdin.write()` returning false was tried as the
|
|
1303
|
+
// signal — never once true would mean this process was never ahead of the
|
|
1304
|
+
// pipe — and measured false on every reading taken while writing this,
|
|
1305
|
+
// including clips this same host decodes at 15-80x with room to spare, so
|
|
1306
|
+
// it does not discriminate: `write()`'s return value tracks Node's own
|
|
1307
|
+
// internal watermark against the size of what was just handed to it, not
|
|
1308
|
+
// real drain state, and answered "no slack" identically whether the pipe
|
|
1309
|
+
// or the decoder was the true limit. Rather than publish a verdict that
|
|
1310
|
+
// reads the same in both cases, only the byte count is kept, for the
|
|
1311
|
+
// MB/s figure below — a number to read, not a boolean to trust.
|
|
1312
|
+
let bytesWritten = 0;
|
|
1301
1313
|
const finish = () => {
|
|
1302
1314
|
if (settled) {
|
|
1303
1315
|
return;
|
|
@@ -1329,11 +1341,25 @@ function decodePipedStream(ffmpegBin, stream) {
|
|
|
1329
1341
|
resolve({ error: lastErrorLine(stderr) || `the window was ${windowSec.toFixed(2)}s of ${producedSec.toFixed(2)}s produced` });
|
|
1330
1342
|
return;
|
|
1331
1343
|
}
|
|
1344
|
+
const speed = producedSec / windowSec;
|
|
1345
|
+
// Diagnostic only — logged, not acted on. The required rate is what
|
|
1346
|
+
// THIS reading implies was needed to keep the decoder fed; the achieved
|
|
1347
|
+
// rate is what this process actually pushed over its whole run (a wider
|
|
1348
|
+
// span than `windowSec`, since the feed starts before the first kept
|
|
1349
|
+
// sample — a looser figure is enough for a sanity comparison).
|
|
1350
|
+
const elapsedSec = (Date.now() - startedAt) / 1000;
|
|
1351
|
+
const achievedMBps = elapsedSec > 0 ? bytesWritten / elapsedSec / 1e6 : 0;
|
|
1352
|
+
const requiredMBps = ((stream.megabitsPerSecond * 1e6) / 8) * speed / 1e6;
|
|
1353
|
+
log.info(
|
|
1354
|
+
`hwaccel: decode pipe fed ${achievedMBps.toFixed(1)} MB/s, ${requiredMBps.toFixed(1)} MB/s ` +
|
|
1355
|
+
`needed for ${speed.toFixed(2)}x — a reading where these are far apart is worth a second look`
|
|
1356
|
+
);
|
|
1332
1357
|
resolve({
|
|
1333
|
-
speed
|
|
1358
|
+
speed,
|
|
1334
1359
|
windowSec,
|
|
1335
1360
|
megapixelsPerSecond: stream.megapixelsPerSecond,
|
|
1336
|
-
megabitsPerSecond: stream.megabitsPerSecond
|
|
1361
|
+
megabitsPerSecond: stream.megabitsPerSecond,
|
|
1362
|
+
pipeThroughputMBps: achievedMBps
|
|
1337
1363
|
});
|
|
1338
1364
|
};
|
|
1339
1365
|
const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
|
|
@@ -1351,8 +1377,13 @@ function decodePipedStream(ffmpegBin, stream) {
|
|
|
1351
1377
|
// Keep the decoder fed. `write` returning false means the pipe is full, and
|
|
1352
1378
|
// the next copy goes on the `drain` — so the decoder is never starved and
|
|
1353
1379
|
// this process never buffers more than the pipe holds.
|
|
1380
|
+
const writeOnce = () => {
|
|
1381
|
+
const accepted = child.stdin.write(stream.bytes);
|
|
1382
|
+
bytesWritten += stream.bytes.length;
|
|
1383
|
+
return accepted;
|
|
1384
|
+
};
|
|
1354
1385
|
const feed = () => {
|
|
1355
|
-
while (!settled && child.stdin.writable &&
|
|
1386
|
+
while (!settled && child.stdin.writable && writeOnce()) {
|
|
1356
1387
|
// Written straight through; go round again.
|
|
1357
1388
|
}
|
|
1358
1389
|
};
|
package/services/torrent-pool.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* @file WebTorrent client pool.
|
|
3
3
|
*
|
|
4
4
|
* Manages a shared WebTorrent client instance and a map of active torrents
|
|
5
|
-
* keyed by
|
|
6
|
-
*
|
|
5
|
+
* keyed by their own infohash (see `torrent-source-key.js`), so a magnet and
|
|
6
|
+
* a `.torrent` file for the same content share one entry. Tracks file-level
|
|
7
|
+
* usage so that only the pieces needed by active streams are selected for
|
|
8
|
+
* download.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import crypto from "node:crypto";
|
|
10
11
|
import dns from "node:dns/promises";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -14,6 +15,7 @@ import { rmSync, statfsSync } from "node:fs";
|
|
|
14
15
|
import WebTorrent from "webtorrent";
|
|
15
16
|
import { logger } from "../utils/logger.js";
|
|
16
17
|
import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
|
|
18
|
+
import { deriveSourceKey } from "./torrent-source-key.js";
|
|
17
19
|
|
|
18
20
|
// The DHT's entry points. Two of the three the library ships answer nothing —
|
|
19
21
|
// measured 2026-08-21 from the addon host: `router.bittorrent.com` and
|
|
@@ -1338,7 +1340,9 @@ export class TorrentPool {
|
|
|
1338
1340
|
* @returns {Promise<import("webtorrent").Torrent>}
|
|
1339
1341
|
*/
|
|
1340
1342
|
async getTorrent(sourceType, source) {
|
|
1341
|
-
|
|
1343
|
+
// Keyed by the torrent's own infohash, not the source bytes — a magnet
|
|
1344
|
+
// and a `.torrent` file for the same content must name the same swarm.
|
|
1345
|
+
const key = await deriveSourceKey(sourceType, source);
|
|
1342
1346
|
|
|
1343
1347
|
// Already resolved — return immediately.
|
|
1344
1348
|
const existing = this.torrents.get(key);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Canonical identity for a torrent source: its own infohash.
|
|
3
|
+
*
|
|
4
|
+
* A magnet URI and a `.torrent` file for the same content are different
|
|
5
|
+
* bytes, so naming a source by a hash of those bytes gave the same film two
|
|
6
|
+
* unrelated keys — one from a magnet, another from a `.torrent` — sharing
|
|
7
|
+
* neither the swarm, nor a cache, nor any work already done. Measured
|
|
8
|
+
* 2026-08-19: `a518ff46…` and `7ab2fb5d…` for one infohash `11f09299…`.
|
|
9
|
+
*
|
|
10
|
+
* The infohash is the one thing both forms carry for the same content, and
|
|
11
|
+
* both carry it synchronously — a magnet's `btih` needs no network, and a
|
|
12
|
+
* `.torrent` file's SHA-1 of its own `info` dictionary needs no metadata
|
|
13
|
+
* exchange either. `parse-torrent` (already in the dependency tree via
|
|
14
|
+
* `webtorrent`) reads either form without touching the network.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import parseTorrent from "parse-torrent";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
21
|
+
* @param {string} source - Magnet URI, or base64-encoded `.torrent` bytes.
|
|
22
|
+
* @returns {Promise<string>} `torrent:<infohash>` — identical for a magnet
|
|
23
|
+
* and a `.torrent` file describing the same content.
|
|
24
|
+
*/
|
|
25
|
+
export async function deriveSourceKey(sourceType, source) {
|
|
26
|
+
const torrentId = sourceType === "torrent" ? Buffer.from(source, "base64") : source;
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = await parseTorrent(torrentId);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
throw new Error(`Could not read an infohash from this ${sourceType}: ${message}`);
|
|
33
|
+
}
|
|
34
|
+
if (!parsed?.infoHash) {
|
|
35
|
+
throw new Error(`Could not read an infohash from this ${sourceType}.`);
|
|
36
|
+
}
|
|
37
|
+
return `torrent:${parsed.infoHash}`;
|
|
38
|
+
}
|
|
@@ -22,22 +22,8 @@
|
|
|
22
22
|
* across calls for the same torrent.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import crypto from "node:crypto";
|
|
26
25
|
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
-
*
|
|
31
|
-
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
-
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
-
*
|
|
34
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
-
* @param {string} source
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function deriveSourceKey(sourceType, source) {
|
|
39
|
-
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
-
}
|
|
26
|
+
import { deriveSourceKey } from "../torrent-source-key.js";
|
|
41
27
|
|
|
42
28
|
/**
|
|
43
29
|
* A torrent pool whose work happens on another thread.
|
|
@@ -65,7 +51,7 @@ export class WorkerTorrentPool {
|
|
|
65
51
|
* @returns {Promise<object>}
|
|
66
52
|
*/
|
|
67
53
|
async getTorrent(sourceType, source) {
|
|
68
|
-
const sourceKey = deriveSourceKey(sourceType, source);
|
|
54
|
+
const sourceKey = await deriveSourceKey(sourceType, source);
|
|
69
55
|
const existing = this.#torrents.get(sourceKey);
|
|
70
56
|
if (existing) {
|
|
71
57
|
return existing;
|
|
@@ -71,3 +71,13 @@ test("the clip's own characteristics come back with the reading", async () => {
|
|
|
71
71
|
assert.ok(measured.megabitsPerSecond > 5 && measured.megabitsPerSecond < 15);
|
|
72
72
|
assert.ok(measured.windowSec >= 0.5, "the slope is taken over a window wide enough to divide by");
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
test("a reading carries what the pipe was actually fed, for item 4(d2)'s sanity check", async () => {
|
|
76
|
+
const measured = await measureDecodeSlope(ffmpegStatic, clip("cal-h264-480-lo.mp4"));
|
|
77
|
+
|
|
78
|
+
assert.ok(measured);
|
|
79
|
+
assert.ok(
|
|
80
|
+
measured.pipeThroughputMBps > 0,
|
|
81
|
+
"the write path pushed a nonzero amount of the clip through, or nothing was measured at all"
|
|
82
|
+
);
|
|
83
|
+
});
|