@torrent-tv/proxy 2.9.119 → 2.9.120

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.9.120
2
+
3
+ - **Fix**: The rest of the file is fetched while the viewer needs nothing, instead of the link sitting idle. The background fill was re-evaluated only when a reader window MOVED, so during exactly the state it exists for — the encoder held back by the look-ahead cap, the viewer comfortably ahead, the link free — nothing was fetched at all. It is now owned by the pool's own timer rather than by the reader, because a parked reader cannot act, and parked is the whole point. Priority 0 against the window's 1, and withdrawn the moment any reader window wants something, so it can never take capacity from the picture.
4
+ - **Fix**: The stall warning stays quiet when a download of zero is correct. It fired all through a healthy session on 2026-08-06 — 65.3% of the file present, the encoder 134-159 s ahead, its window complete — and a warning that goes off when everything is right teaches the reader to ignore it. It now checks whether any reader window is actually missing a piece before saying anything.
5
+ - **New**: The progress response carries what this host takes to create a session and to produce a first segment. Both are on the playback plan too, but the browser reads that once per file: measured 2026-08-06 across four seeks, a proxy that had just restarted answered null for both, so every later seek estimated the wait with one term of four — the figure reached zero after 3.5 s of an 11.8 s wait and read "starting now" for the remaining 8.4 s. This response is polled about every 1.5 s.
6
+
1
7
  ## 2.9.119
2
8
 
3
9
  - **Fix**: The progress report says the height the viewer is actually watching, not only the one an encoder is producing. It was zero whenever the video was copied — which is most sessions — so the quality menu read a bare "Auto" in exactly the case it was built to explain. Copying reports the source height, re-encoding reports the rung the proxy has settled on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.119",
3
+ "version": "2.9.120",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -3504,6 +3504,15 @@ export class HlsSessionManager {
3504
3504
  currentHeight: session.transcodeVideo
3505
3505
  ? (session.encodeHeight ?? session.sourceHeight ?? 0)
3506
3506
  : (session.sourceHeight ?? 0),
3507
+ // What this host takes to create a session and to make a first segment.
3508
+ // Also on the playback plan, but the browser reads that once per file:
3509
+ // measured 2026-08-06 across four seeks, a proxy that had just restarted
3510
+ // answered null for both, and every later seek then computed its estimate
3511
+ // with one term of four — the figure hit zero after 3.5 s of an 11.8 s
3512
+ // wait and read "starting now" for the remaining 8.4 s. This response is
3513
+ // polled about every 1.5 s, so carrying them here keeps them current.
3514
+ expectedSessionCreateMs: this.expectedSessionCreateMs(),
3515
+ expectedFirstSegmentMs: this.expectedFirstSegmentMs(),
3507
3516
  updatedAt: session.progress.updatedAt,
3508
3517
  error: session.state === "failed" ? session.lastError : ""
3509
3518
  };
@@ -450,6 +450,14 @@ export class TorrentPool {
450
450
  */
451
451
  #readPositionByTorrent = new Map();
452
452
 
453
+ /**
454
+ * The background-fill selection held for each torrent, so it can be withdrawn
455
+ * again. See #updateBackgroundFill.
456
+ *
457
+ * @type {Map<import("webtorrent").Torrent, { from: number, to: number }>}
458
+ */
459
+ #backgroundFill = new Map();
460
+
453
461
  /** When each torrent's download first fell below the stall threshold. */
454
462
  #stallSince = new Map();
455
463
  /** When each torrent's stall was last reported, so it is not repeated hotly. */
@@ -663,6 +671,97 @@ export class TorrentPool {
663
671
  }
664
672
  }
665
673
 
674
+ /**
675
+ * The reader windows of a torrent, and whether any of them still wants
676
+ * something. Two questions with one answer, because both callers below need
677
+ * exactly this: the background fill may only run when nothing is wanted, and
678
+ * a download sitting at zero is only worth warning about when something is.
679
+ *
680
+ * @param {import("webtorrent").Torrent} torrent
681
+ * @returns {{ ranges: Array<{ from: number, to: number }>, missing: boolean }}
682
+ */
683
+ #readerDemand(torrent) {
684
+ const store = findSharedStore(torrent);
685
+ const ranges = typeof store?.protectedRanges === "function" ? store.protectedRanges() : [];
686
+ let missing = false;
687
+ for (const range of ranges) {
688
+ for (let index = range.from; index <= range.to; index += 1) {
689
+ if (!torrent.bitfield?.get(index)) {
690
+ missing = true;
691
+ break;
692
+ }
693
+ }
694
+ if (missing) {
695
+ break;
696
+ }
697
+ }
698
+ return { ranges, missing };
699
+ }
700
+
701
+ /**
702
+ * Keep fetching the rest of the file while the viewer needs nothing.
703
+ *
704
+ * Owned here rather than by the reader, because the reader cannot act while
705
+ * it is parked — and parked is exactly the state this is for. The encoder is
706
+ * held back by the look-ahead cap, the viewer is comfortably ahead, the link
707
+ * is idle: that is the cheapest bandwidth of the whole session and it was
708
+ * going unused, because the fill was re-evaluated only when a reader window
709
+ * MOVED. Priority 0 against the window's 1, and withdrawn the moment any
710
+ * window wants something, so it can never take capacity from the picture.
711
+ *
712
+ * @param {import("webtorrent").Torrent} torrent
713
+ * @param {{ ranges: Array<{ from: number, to: number }>, missing: boolean }} demand
714
+ * @returns {void}
715
+ */
716
+ #updateBackgroundFill(torrent, demand) {
717
+ const held = this.#backgroundFill.get(torrent) ?? null;
718
+ const wanted = !demand.missing && demand.ranges.length > 0
719
+ ? this.#tailAfterWindows(torrent, demand.ranges)
720
+ : null;
721
+ if (held && (!wanted || held.from !== wanted.from || held.to !== wanted.to)) {
722
+ try {
723
+ torrent._deselect?.(held.from, held.to, false);
724
+ } catch {
725
+ // Best effort.
726
+ }
727
+ this.#backgroundFill.delete(torrent);
728
+ }
729
+ if (wanted && !this.#backgroundFill.has(torrent)) {
730
+ try {
731
+ torrent._select?.(wanted.from, wanted.to, 0, null, false);
732
+ this.#backgroundFill.set(torrent, wanted);
733
+ } catch {
734
+ // Best effort.
735
+ }
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Everything after the furthest reader window, up to the end of the file it
741
+ * belongs to. Null when there is nothing left.
742
+ *
743
+ * @param {import("webtorrent").Torrent} torrent
744
+ * @param {Array<{ from: number, to: number }>} ranges
745
+ * @returns {{ from: number, to: number } | null}
746
+ */
747
+ #tailAfterWindows(torrent, ranges) {
748
+ const pieceLength = Number(torrent.pieceLength);
749
+ if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
750
+ return null;
751
+ }
752
+ const usage = this.fileUsageByTorrent.get(torrent);
753
+ let lastPiece = -1;
754
+ for (const [fileIndex, count] of usage ?? []) {
755
+ const file = count > 0 ? torrent.files?.[fileIndex] : null;
756
+ if (!file) {
757
+ continue;
758
+ }
759
+ lastPiece = Math.max(lastPiece, Math.floor((file.offset + file.length - 1) / pieceLength));
760
+ }
761
+ const from = Math.max(...ranges.map((range) => range.to)) + 1;
762
+ return lastPiece >= from ? { from, to: lastPiece } : null;
763
+ }
764
+
666
765
  #reportStalledDownloads() {
667
766
  const now = Date.now();
668
767
  for (const torrent of this.torrents.values()) {
@@ -675,6 +774,16 @@ export class TorrentPool {
675
774
  this.#stallSince.delete(torrent);
676
775
  continue;
677
776
  }
777
+ // Nothing is being downloaded because nothing is wanted: every reader has
778
+ // all the pieces of its window. That is the encoder being held back, not
779
+ // a fault, and it warned all through a healthy session on 2026-08-06 —
780
+ // 65.3% of the file present, the encoder 134-159 s ahead, its window
781
+ // complete. A warning that fires when everything is right teaches the
782
+ // reader to ignore it.
783
+ if (!this.#readerDemand(torrent).missing) {
784
+ this.#stallSince.delete(torrent);
785
+ continue;
786
+ }
678
787
  const since = this.#stallSince.get(torrent) ?? now;
679
788
  this.#stallSince.set(torrent, since);
680
789
  if (now - since < STALL_REPORT_AFTER_MS) {
@@ -703,6 +812,13 @@ export class TorrentPool {
703
812
  Date.now()
704
813
  );
705
814
  this.#reassertReaderWindows();
815
+ for (const torrent of this.torrents.values()) {
816
+ const usage = this.fileUsageByTorrent.get(torrent);
817
+ if (!usage || usage.size === 0 || torrent?.done === true) {
818
+ continue;
819
+ }
820
+ this.#updateBackgroundFill(torrent, this.#readerDemand(torrent));
821
+ }
706
822
  this.#reportStalledDownloads();
707
823
  const { bytesPerSec, reason } = decideUploadLimit(active);
708
824
  if (bytesPerSec === this.#uploadLimit) {
@@ -322,90 +322,18 @@ export async function* readFragments({
322
322
  * @type {(() => void) | null}
323
323
  */
324
324
  let releaseHeldPin = null;
325
- /**
326
- * The rest of the file, claimed at the lowest priority so it is fetched with
327
- * whatever capacity the near window does not need. Null whenever it must not
328
- * be fetched at all — see {@link updateBackfill}.
329
- *
330
- * @type {{ from: number, to: number } | null}
331
- */
332
- let backfill = null;
333
325
 
334
326
  // Identity of this read, so the store can tell one reader's window from
335
327
  // another's. Each read gets its own; `readerSequence` never repeats within a
336
328
  // process.
337
329
  const readerId = `read-${(readerSequence += 1)}`;
338
330
 
339
- /**
340
- * Whether every piece of the current window is already downloaded.
341
- *
342
- * A handful of pieces, checked against the bitfield, so this is cheap enough
343
- * to re-run on every window move.
344
- *
345
- * @returns {boolean}
346
- */
347
- const windowIsComplete = () => {
348
- if (!window) {
349
- return false;
350
- }
351
- for (let index = window.from; index <= window.to; index += 1) {
352
- if (!torrent.bitfield?.get(index)) {
353
- return false;
354
- }
355
- }
356
- return true;
357
- };
358
-
359
- /**
360
- * Keep the rest of the file downloading in the background, but ONLY while
361
- * that cannot cost the viewer anything.
362
- *
363
- * The rule is deliberately blunt rather than clever: the tail is in the
364
- * download set only when every piece of the near window is already on hand.
365
- * The moment one is missing — the window slid onto undownloaded content, or a
366
- * seek moved it somewhere new — the tail leaves the set, so the swarm has
367
- * nothing else to work on. Relying on WebTorrent's priority ordering alone
368
- * would be weaker: it decides which selection a wire is offered FIRST, not
369
- * what a wire already has outstanding, so a seek would still queue behind
370
- * whatever tail blocks were in flight.
371
- *
372
- * Priority 0 against the window's 1 is kept as well, for the moments between
373
- * one evaluation and the next.
374
- *
375
- * What this buys: a file watched for a while ends up downloaded, and every
376
- * later seek into it is instant. What it costs: the pool owner's bandwidth
377
- * and disk for a film the viewer may abandon — which is why it never runs
378
- * ahead of the viewer's own needs.
379
- *
380
- * @returns {void}
381
- */
382
- const updateBackfill = () => {
383
- const wanted = window && window.to < lastPiece && windowIsComplete()
384
- ? { from: window.to + 1, to: lastPiece }
385
- : null;
386
- if (backfill && (!wanted || backfill.from !== wanted.from || backfill.to !== wanted.to)) {
387
- releaseWindow(torrent, backfill);
388
- backfill = null;
389
- }
390
- if (wanted && !backfill) {
391
- claimWindow(torrent, wanted, 0);
392
- backfill = wanted;
393
- }
394
- };
395
-
396
331
  const moveWindowTo = (pieceIndex) => {
397
332
  const next = readWindowFor({ pieceIndex, lastPiece, windowPieces });
398
333
  if (window && window.from === next.from && window.to === next.to) {
399
- updateBackfill();
400
334
  return;
401
335
  }
402
336
  const isJump = !window || next.from > window.to || next.from < window.from;
403
- // Whatever was being fetched for later stops being wanted the instant the
404
- // window moves: the new position has to have the whole swarm to itself.
405
- if (backfill) {
406
- releaseWindow(torrent, backfill);
407
- backfill = null;
408
- }
409
337
  if (window) {
410
338
  releaseWindow(torrent, window);
411
339
  }
@@ -429,9 +357,6 @@ export async function* readFragments({
429
357
  );
430
358
  }
431
359
  }
432
- // Only now, with the new window claimed and marked, may anything else be
433
- // asked for — and only if the window needs nothing.
434
- updateBackfill();
435
360
  };
436
361
 
437
362
  try {
@@ -555,9 +480,6 @@ export async function* readFragments({
555
480
  // Reached on completion, on cancellation, on a throw, and when the consumer
556
481
  // stops iterating — a window left behind would keep the swarm fetching for
557
482
  // a reader that no longer exists.
558
- if (backfill) {
559
- releaseWindow(torrent, backfill);
560
- }
561
483
  if (window) {
562
484
  releaseWindow(torrent, window);
563
485
  }