@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.
@@ -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
  }
@@ -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
+ });