@torrent-tv/proxy 2.9.104 → 2.9.106
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 +1 -1
- package/routes/api/sources/stats/get.js +82 -69
- package/services/piece-store/piece-lru.js +12 -0
- package/services/piece-store/shared-piece-store.js +672 -663
- package/services/playback-planner.js +29 -6
- package/services/torrent-pool.js +69 -1
- package/test/plan-host-timings.test.js +68 -0
|
@@ -286,6 +286,31 @@ export function createPlaybackPlanner({
|
|
|
286
286
|
*/
|
|
287
287
|
const mediaInfoCache = new Map();
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Attach what this host currently measures itself taking to create a session
|
|
291
|
+
* and to produce a first segment.
|
|
292
|
+
*
|
|
293
|
+
* Read at RESPONSE time, deliberately. Both are medians of sessions that have
|
|
294
|
+
* already finished on this host, so at the moment a plan is BUILT the very
|
|
295
|
+
* first file opened after a restart has none and gets `null` — and the plan
|
|
296
|
+
* is then cached, so that file kept answering `null` for the life of the
|
|
297
|
+
* process however many sessions ran afterwards. Measured 2026-08-05: a fresh
|
|
298
|
+
* 2.9.103 answered `null` for both, then produced the session in 6 ms and the
|
|
299
|
+
* first segment in 21 479 ms. The figures existed; the plan could not carry
|
|
300
|
+
* them, and the browser's estimate fell back to its own guess in exactly the
|
|
301
|
+
* cold-start case the feature was built for.
|
|
302
|
+
*
|
|
303
|
+
* @param {PlaybackPlan} plan
|
|
304
|
+
* @returns {PlaybackPlan}
|
|
305
|
+
*/
|
|
306
|
+
function withHostTimings(plan) {
|
|
307
|
+
return {
|
|
308
|
+
...plan,
|
|
309
|
+
expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
|
|
310
|
+
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
289
314
|
return {
|
|
290
315
|
/**
|
|
291
316
|
* Media info the planner already probed for this file, or `null`. Lets the
|
|
@@ -322,7 +347,7 @@ export function createPlaybackPlanner({
|
|
|
322
347
|
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
323
348
|
const cached = cache.get(cacheKey);
|
|
324
349
|
if (cached) {
|
|
325
|
-
return cached;
|
|
350
|
+
return withHostTimings(cached);
|
|
326
351
|
}
|
|
327
352
|
// Where the time before playback goes. `cold-start` already breaks down
|
|
328
353
|
// everything from the transcode-session request onwards, but the plan
|
|
@@ -365,7 +390,7 @@ export function createPlaybackPlanner({
|
|
|
365
390
|
subtitleTracks: []
|
|
366
391
|
};
|
|
367
392
|
cache.set(cacheKey, plan);
|
|
368
|
-
return plan;
|
|
393
|
+
return withHostTimings(plan);
|
|
369
394
|
}
|
|
370
395
|
|
|
371
396
|
// Pre-fetch file edges (head + tail), then probe — retrying while the
|
|
@@ -402,8 +427,6 @@ export function createPlaybackPlanner({
|
|
|
402
427
|
}
|
|
403
428
|
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
404
429
|
const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
|
|
405
|
-
const firstSegmentMs = expectedFirstSegmentMs?.() ?? null;
|
|
406
|
-
const sessionCreateMs = expectedSessionCreateMs?.() ?? null;
|
|
407
430
|
logger.info(
|
|
408
431
|
`plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
|
|
409
432
|
`file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
|
|
@@ -463,9 +486,9 @@ export function createPlaybackPlanner({
|
|
|
463
486
|
timeoutMs: 60_000
|
|
464
487
|
})
|
|
465
488
|
.catch(() => {});
|
|
466
|
-
return plan;
|
|
489
|
+
return withHostTimings(plan);
|
|
467
490
|
}
|
|
468
|
-
return { ...plan, pending: true };
|
|
491
|
+
return withHostTimings({ ...plan, pending: true });
|
|
469
492
|
}
|
|
470
493
|
};
|
|
471
494
|
}
|
package/services/torrent-pool.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import { rmSync, statfsSync } from "node:fs";
|
|
13
13
|
import WebTorrent from "webtorrent";
|
|
14
14
|
import { logger } from "../utils/logger.js";
|
|
15
|
-
import { SharedPieceStore } from "./piece-store/shared-piece-store.js";
|
|
15
|
+
import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
|
|
16
16
|
|
|
17
17
|
// WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
|
|
18
18
|
// path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
|
|
@@ -596,6 +596,73 @@ export class TorrentPool {
|
|
|
596
596
|
*
|
|
597
597
|
* @returns {void}
|
|
598
598
|
*/
|
|
599
|
+
/**
|
|
600
|
+
* Put back the reader windows WebTorrent has quietly dropped.
|
|
601
|
+
*
|
|
602
|
+
* A reader claims its window as a stream selection and releases it when it
|
|
603
|
+
* ends. That claim is NOT durable: `_gcSelections` deletes a selection the
|
|
604
|
+
* moment every piece in it is present, so a window that has been satisfied
|
|
605
|
+
* stops existing — and with it, everything the swarm had been asked for.
|
|
606
|
+
*
|
|
607
|
+
* While a reader keeps moving that is invisible, because the next window is
|
|
608
|
+
* claimed immediately. It becomes fatal when the reader STOPS: the encoder is
|
|
609
|
+
* held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a
|
|
610
|
+
* window that is fully downloaded, the selection disappears — and nothing our
|
|
611
|
+
* code runs can notice, because the reader is parked inside a write. Measured
|
|
612
|
+
* 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at
|
|
613
|
+
* 22:45:05 and stayed there for **eleven minutes** with 150 peer connections
|
|
614
|
+
* open, `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in
|
|
615
|
+
* flight`. When the encoder was let go there was nothing ahead of it, and the
|
|
616
|
+
* viewer's picture stopped.
|
|
617
|
+
*
|
|
618
|
+
* So the claim is re-asserted from outside, on this timer, using the windows
|
|
619
|
+
* the store already knows about — those are declared by live readers and
|
|
620
|
+
* withdrawn when they end, which is exactly the set that should be selected.
|
|
621
|
+
*
|
|
622
|
+
* @returns {void}
|
|
623
|
+
*/
|
|
624
|
+
#reassertReaderWindows() {
|
|
625
|
+
for (const torrent of this.torrents.values()) {
|
|
626
|
+
const usage = this.fileUsageByTorrent.get(torrent);
|
|
627
|
+
if (!usage || usage.size === 0 || torrent?.done === true) {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const store = findSharedStore(torrent);
|
|
631
|
+
const ranges = typeof store?.protectedRanges === "function" ? store.protectedRanges() : [];
|
|
632
|
+
if (ranges.length === 0) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const items = Array.isArray(torrent?._selections?._items) ? torrent._selections._items : [];
|
|
636
|
+
for (const range of ranges) {
|
|
637
|
+
const present = items.some((item) => item?.from === range.from && item?.to === range.to);
|
|
638
|
+
if (present) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// Only worth re-claiming what is actually missing: re-claiming a window
|
|
642
|
+
// that is already complete would be deleted again on the next pass and
|
|
643
|
+
// the two would take turns forever.
|
|
644
|
+
let missing = false;
|
|
645
|
+
for (let index = range.from; index <= range.to && !missing; index += 1) {
|
|
646
|
+
if (!torrent.bitfield?.get(index)) {
|
|
647
|
+
missing = true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (!missing) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
torrent._select(range.from, range.to, 1, null, true);
|
|
655
|
+
logger.info(
|
|
656
|
+
`torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] re-claimed reader window ` +
|
|
657
|
+
`${range.from}-${range.to} — the selection had been dropped once it was satisfied`
|
|
658
|
+
);
|
|
659
|
+
} catch {
|
|
660
|
+
// Best effort: a torrent being torn down is not worth failing over.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
599
666
|
#reportStalledDownloads() {
|
|
600
667
|
const now = Date.now();
|
|
601
668
|
for (const torrent of this.torrents.values()) {
|
|
@@ -635,6 +702,7 @@ export class TorrentPool {
|
|
|
635
702
|
this.fileUsageByTorrent,
|
|
636
703
|
Date.now()
|
|
637
704
|
);
|
|
705
|
+
this.#reassertReaderWindows();
|
|
638
706
|
this.#reportStalledDownloads();
|
|
639
707
|
const { bytesPerSec, reason } = decideUploadLimit(active);
|
|
640
708
|
if (bytesPerSec === this.#uploadLimit) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The host's own timings must reach the browser for the file that needs
|
|
3
|
+
* them most.
|
|
4
|
+
*
|
|
5
|
+
* The plan carries two figures the browser cannot measure for itself: how long
|
|
6
|
+
* this host takes to create a session, and how long it takes to produce a first
|
|
7
|
+
* segment. Both are medians of sessions that have already finished here, so at
|
|
8
|
+
* the moment a plan is BUILT the very first file opened after a restart has
|
|
9
|
+
* neither — and the plan is cached, so that file kept answering `null` for the
|
|
10
|
+
* life of the process however many sessions ran afterwards. Measured
|
|
11
|
+
* 2026-08-05: a fresh proxy answered `null` for both, then created the session
|
|
12
|
+
* in 6 ms and produced the first segment in 21 479 ms.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { createPlaybackPlanner } from "../services/playback-planner.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A planner whose host timings can be changed between calls, over a source that
|
|
21
|
+
* needs no torrent: transcoding disabled short-circuits to a cached direct plan
|
|
22
|
+
* without probing anything.
|
|
23
|
+
*/
|
|
24
|
+
function plannerWithTimings() {
|
|
25
|
+
const timings = { create: null, first: null };
|
|
26
|
+
const planner = createPlaybackPlanner({
|
|
27
|
+
ffmpegBin: "ffmpeg",
|
|
28
|
+
localBaseUrl: "http://127.0.0.1:9090/stream",
|
|
29
|
+
sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:0" }) },
|
|
30
|
+
torrentPool: { getTorrent: async () => ({ files: [{ name: "a.mkv", length: 10 }] }) },
|
|
31
|
+
transcodeAudioEnabled: false,
|
|
32
|
+
expectedSessionCreateMs: () => timings.create,
|
|
33
|
+
expectedFirstSegmentMs: () => timings.first
|
|
34
|
+
});
|
|
35
|
+
return { planner, timings };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("a plan built before the host had measured anything still reports them later", async () => {
|
|
39
|
+
const { planner, timings } = plannerWithTimings();
|
|
40
|
+
|
|
41
|
+
const cold = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
42
|
+
assert.equal(cold.expectedSessionCreateMs, null, "nothing has finished yet, so there is nothing to report");
|
|
43
|
+
assert.equal(cold.expectedFirstSegmentMs, null);
|
|
44
|
+
|
|
45
|
+
// Sessions run; the host now knows what it costs.
|
|
46
|
+
timings.create = 6;
|
|
47
|
+
timings.first = 21_479;
|
|
48
|
+
|
|
49
|
+
const warm = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
50
|
+
assert.equal(
|
|
51
|
+
warm.expectedSessionCreateMs,
|
|
52
|
+
6,
|
|
53
|
+
"the cached plan withheld the figure the browser needed"
|
|
54
|
+
);
|
|
55
|
+
assert.equal(warm.expectedFirstSegmentMs, 21_479);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the figures follow the host as they change", async () => {
|
|
59
|
+
const { planner, timings } = plannerWithTimings();
|
|
60
|
+
timings.create = 100;
|
|
61
|
+
timings.first = 800;
|
|
62
|
+
const first = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
63
|
+
assert.equal(first.expectedFirstSegmentMs, 800);
|
|
64
|
+
|
|
65
|
+
timings.first = 7_000; // a re-encode session — an order of magnitude slower
|
|
66
|
+
const later = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
67
|
+
assert.equal(later.expectedFirstSegmentMs, 7_000, "the plan reported a stale figure");
|
|
68
|
+
});
|