@torrent-tv/proxy 2.105.10 → 2.105.12
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/package.json
CHANGED
|
@@ -483,7 +483,8 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
|
|
|
483
483
|
*
|
|
484
484
|
* `paused` is the library's own word for this and the path it already checks,
|
|
485
485
|
* so nothing here fights it. The pause alone does not close what is already
|
|
486
|
-
* open, so the peers are let go by hand; the data stays exactly where it is
|
|
486
|
+
* open, so the peers are let go by hand; the data stays exactly where it is,
|
|
487
|
+
* and so do the addresses dialled, for the next rejoin (`rememberSwarmAddresses`).
|
|
487
488
|
*
|
|
488
489
|
* @param {import("webtorrent").Torrent} torrent
|
|
489
490
|
* @returns {number} Connections let go.
|
|
@@ -494,6 +495,8 @@ export function leaveSwarm(torrent) {
|
|
|
494
495
|
}
|
|
495
496
|
torrent.pause();
|
|
496
497
|
const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
|
|
498
|
+
const remembered = rememberSwarmAddresses(torrent);
|
|
499
|
+
const unanswered = describeUnansweredRejoin(torrent);
|
|
497
500
|
let closed = 0;
|
|
498
501
|
for (const peer of [...(torrent._peers?.values?.() ?? [])]) {
|
|
499
502
|
try {
|
|
@@ -505,11 +508,150 @@ export function leaveSwarm(torrent) {
|
|
|
505
508
|
}
|
|
506
509
|
logger.info(
|
|
507
510
|
`torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] left the swarm — its last reader went, ` +
|
|
508
|
-
`${open} connection(s) open, ${closed} let go
|
|
511
|
+
`${open} connection(s) open, ${closed} let go, ${remembered} address(es) kept for the next reader` +
|
|
512
|
+
`${unanswered}; the data stays and the next reader rejoins`
|
|
509
513
|
);
|
|
510
514
|
return closed;
|
|
511
515
|
}
|
|
512
516
|
|
|
517
|
+
/**
|
|
518
|
+
* WebTorrent's own `maxConns` when the client states none (webtorrent 2.8.5,
|
|
519
|
+
* index.js). More remembered addresses than this could not be dialled at once.
|
|
520
|
+
*/
|
|
521
|
+
const WEBTORRENT_DEFAULT_MAX_CONNS = 55;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The addresses a torrent dialled in its swarm, kept for every later rejoin.
|
|
525
|
+
*
|
|
526
|
+
* `leaveSwarm` destroys every peer, and a destroyed peer leaves the torrent's
|
|
527
|
+
* `_peers` and its dial queue with it, so a torrent that rejoined had nobody to
|
|
528
|
+
* dial: `resume()` only drains that empty queue, and nothing announces again
|
|
529
|
+
* until the periodic discovery round — fifteen minutes by default, longer when
|
|
530
|
+
* a tracker says so. Home Assistant 2026-10-23, torrent 507d1fa9: it rejoined
|
|
531
|
+
* with "0 connected of 0 known", the page got no segment for 73 s, and every
|
|
532
|
+
* later opening of the same film started with no peer (torrent-tv/meta#238).
|
|
533
|
+
*
|
|
534
|
+
* KEPT FOR THE LIFE OF THE TORRENT OBJECT and refreshed at each departure, not
|
|
535
|
+
* consumed by a rejoin: `addPeer` accepts a candidate, it does not connect one,
|
|
536
|
+
* and a torrent that leaves again before a candidate answered would otherwise
|
|
537
|
+
* take an empty snapshot and lose them all.
|
|
538
|
+
*
|
|
539
|
+
* @type {WeakMap<object, string[]>}
|
|
540
|
+
*/
|
|
541
|
+
const swarmAddresses = new WeakMap();
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Torrents with a DHT lookup started by a rejoin and not yet answered: a
|
|
545
|
+
* second rejoin meanwhile does not start another.
|
|
546
|
+
*
|
|
547
|
+
* @type {WeakSet<object>}
|
|
548
|
+
*/
|
|
549
|
+
const swarmLookups = new WeakSet();
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* When each torrent last rejoined, on the monotonic clock, and whether a peer
|
|
553
|
+
* and data have come since — what tells a restored swarm from a rejoin that
|
|
554
|
+
* dialled addresses nobody answers at any more.
|
|
555
|
+
*
|
|
556
|
+
* @type {WeakMap<object, { at: number, firstPeerSeconds: number | null, firstDataSeconds: number | null }>}
|
|
557
|
+
*/
|
|
558
|
+
const swarmRejoins = new WeakMap();
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Refresh the addresses kept for a torrent from the peers it has now, before
|
|
562
|
+
* they are let go. Outgoing peers only: an incoming peer's address carries the
|
|
563
|
+
* port its side connected FROM, which nobody listens on. Connected ones first,
|
|
564
|
+
* then the candidates still queued or connecting, then what was kept before;
|
|
565
|
+
* no address twice, and no more than the client may dial at once. A departure
|
|
566
|
+
* that finds no outgoing peer keeps the earlier list.
|
|
567
|
+
*
|
|
568
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
569
|
+
* @returns {number} How many addresses are kept.
|
|
570
|
+
*/
|
|
571
|
+
export function rememberSwarmAddresses(torrent) {
|
|
572
|
+
const limit = Number(torrent?.client?.maxConns) > 0 ? Number(torrent.client.maxConns) : WEBTORRENT_DEFAULT_MAX_CONNS;
|
|
573
|
+
const outgoing = [...(torrent?._peers?.values?.() ?? [])]
|
|
574
|
+
.filter((peer) => (peer?.type === "tcpOutgoing" || peer?.type === "utpOutgoing") &&
|
|
575
|
+
typeof peer.addr === "string" && peer.addr.length > 0);
|
|
576
|
+
const kept = [];
|
|
577
|
+
const seen = new Set();
|
|
578
|
+
for (const addr of [
|
|
579
|
+
...outgoing.filter((peer) => peer.wire).map((peer) => peer.addr),
|
|
580
|
+
...outgoing.filter((peer) => !peer.wire).map((peer) => peer.addr),
|
|
581
|
+
...(swarmAddresses.get(torrent) ?? [])
|
|
582
|
+
]) {
|
|
583
|
+
if (kept.length >= limit) break;
|
|
584
|
+
if (seen.has(addr)) continue;
|
|
585
|
+
seen.add(addr);
|
|
586
|
+
kept.push(addr);
|
|
587
|
+
}
|
|
588
|
+
if (kept.length > 0) swarmAddresses.set(torrent, kept);
|
|
589
|
+
return kept.length;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Said when a torrent leaves its swarm again: what the previous rejoin got.
|
|
594
|
+
*
|
|
595
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
596
|
+
* @returns {string}
|
|
597
|
+
*/
|
|
598
|
+
function describeUnansweredRejoin(torrent) {
|
|
599
|
+
const rejoin = swarmRejoins.get(torrent);
|
|
600
|
+
if (!rejoin || rejoin.firstDataSeconds !== null) return "";
|
|
601
|
+
const seconds = ((performance.now() - rejoin.at) / 1000).toFixed(1);
|
|
602
|
+
return rejoin.firstPeerSeconds === null
|
|
603
|
+
? ` (no peer and no data in the ${seconds}s since it rejoined)`
|
|
604
|
+
: ` (a peer after ${rejoin.firstPeerSeconds.toFixed(1)}s, no data in the ${seconds}s since it rejoined)`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Ask the DHT for the torrent's peers again, unless a lookup a rejoin started
|
|
609
|
+
* is still running. A private torrent has no DHT (WebTorrent passes `dht:
|
|
610
|
+
* false` to its discovery), and the kept addresses are then all it has.
|
|
611
|
+
*
|
|
612
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
613
|
+
* @param {() => string} label
|
|
614
|
+
* @returns {string} What was done, for the rejoin line.
|
|
615
|
+
*/
|
|
616
|
+
function lookUpSwarm(torrent, label) {
|
|
617
|
+
const dht = torrent.discovery?.dht;
|
|
618
|
+
if (!dht || typeof dht.lookup !== "function" || typeof torrent.infoHash !== "string") return "no DHT";
|
|
619
|
+
if (swarmLookups.has(torrent)) return "DHT lookup already running";
|
|
620
|
+
swarmLookups.add(torrent);
|
|
621
|
+
try {
|
|
622
|
+
// Found peers arrive through the discovery's own "peer" listener, which
|
|
623
|
+
// hands them to `addPeer`. The callback only ends the lookup.
|
|
624
|
+
dht.lookup(torrent.infoHash, (error) => {
|
|
625
|
+
swarmLookups.delete(torrent);
|
|
626
|
+
if (error) logger.warn(`torrent-pool: [${label()}] DHT lookup after rejoin failed: ${error?.message ?? error}`);
|
|
627
|
+
});
|
|
628
|
+
} catch (error) {
|
|
629
|
+
swarmLookups.delete(torrent);
|
|
630
|
+
logger.warn(`torrent-pool: [${label()}] DHT lookup after rejoin failed: ${error?.message ?? error}`);
|
|
631
|
+
return "DHT lookup failed";
|
|
632
|
+
}
|
|
633
|
+
return "DHT lookup started";
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Note the first peer, or the first downloaded bytes, since the torrent's last
|
|
638
|
+
* rejoin, once each.
|
|
639
|
+
*
|
|
640
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
641
|
+
* @param {"peer" | "data"} kind
|
|
642
|
+
* @returns {boolean} Whether this was the first of its kind since the rejoin.
|
|
643
|
+
*/
|
|
644
|
+
export function noteAfterRejoin(torrent, kind) {
|
|
645
|
+
const rejoin = swarmRejoins.get(torrent);
|
|
646
|
+
if (!rejoin) return false;
|
|
647
|
+
const field = kind === "peer" ? "firstPeerSeconds" : "firstDataSeconds";
|
|
648
|
+
if (rejoin[field] !== null) return false;
|
|
649
|
+
rejoin[field] = (performance.now() - rejoin.at) / 1000;
|
|
650
|
+
logger.info(`torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] after rejoin: first ` +
|
|
651
|
+
`${kind} after ${rejoin[field].toFixed(1)}s`);
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
|
|
513
655
|
/**
|
|
514
656
|
* Take the swarm back for a torrent a reader has just arrived at.
|
|
515
657
|
*
|
|
@@ -517,6 +659,12 @@ export function leaveSwarm(torrent) {
|
|
|
517
659
|
* back. Both are acted on at the moment they happen, and neither is discovered
|
|
518
660
|
* by asking.
|
|
519
661
|
*
|
|
662
|
+
* `resume()` in WebTorrent 2.8.5 only clears `paused` and dials what is in the
|
|
663
|
+
* torrent's queue, which `leaveSwarm` emptied; it announces nothing. So the
|
|
664
|
+
* addresses kept at the departure are handed back, and the DHT is asked again.
|
|
665
|
+
* The trackers stay on their own schedule: each sets its own interval, and an
|
|
666
|
+
* announce at every rejoin would not respect it.
|
|
667
|
+
*
|
|
520
668
|
* @param {import("webtorrent").Torrent} torrent
|
|
521
669
|
* @returns {boolean} Whether it had to be taken back.
|
|
522
670
|
*/
|
|
@@ -525,15 +673,33 @@ export function rejoinSwarm(torrent) {
|
|
|
525
673
|
return false;
|
|
526
674
|
}
|
|
527
675
|
torrent.resume();
|
|
528
|
-
|
|
529
|
-
// explicitly closed by leaveSwarm().
|
|
676
|
+
const label = () => String(torrent.infoHash ?? "?").slice(0, 8);
|
|
677
|
+
// resume() does not recreate HTTP seeds explicitly closed by leaveSwarm().
|
|
678
|
+
// Their URLs remain in the metadata.
|
|
530
679
|
if (torrent.client?.enableWebSeeds !== false && typeof torrent.addWebSeed === "function") {
|
|
531
680
|
for (const url of torrent.urlList ?? []) {
|
|
532
681
|
if (!torrent._peers?.has(url)) torrent.addWebSeed(url);
|
|
533
682
|
}
|
|
534
683
|
}
|
|
684
|
+
swarmRejoins.set(torrent, { at: performance.now(), firstPeerSeconds: null, firstDataSeconds: null });
|
|
685
|
+
// A torrent held whole has nothing to fetch from anybody: dialling its swarm
|
|
686
|
+
// again would only bring back the connections leaving it was for.
|
|
687
|
+
const whole = torrent.progress === 1;
|
|
688
|
+
const addresses = whole ? [] : swarmAddresses.get(torrent) ?? [];
|
|
689
|
+
let handed = 0;
|
|
690
|
+
if (typeof torrent.addPeer === "function") {
|
|
691
|
+
for (const addr of addresses) {
|
|
692
|
+
try {
|
|
693
|
+
if (torrent.addPeer(addr, "rejoin")) handed += 1;
|
|
694
|
+
} catch {
|
|
695
|
+
// An address the library refuses now: the others are still worth dialling.
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const lookup = whole ? "held whole, no DHT lookup" : lookUpSwarm(torrent, label);
|
|
535
700
|
logger.info(
|
|
536
|
-
`torrent-pool: [${
|
|
701
|
+
`torrent-pool: [${label()}] rejoined the swarm — a reader arrived; ` +
|
|
702
|
+
`${handed} of ${addresses.length} kept address(es) handed back, ${lookup}`
|
|
537
703
|
);
|
|
538
704
|
return true;
|
|
539
705
|
}
|
|
@@ -1712,6 +1878,11 @@ export class TorrentPool {
|
|
|
1712
1878
|
);
|
|
1713
1879
|
});
|
|
1714
1880
|
|
|
1881
|
+
// What a rejoin restored: its first connection and its first downloaded
|
|
1882
|
+
// bytes, once each per rejoin (torrent-tv/meta#238).
|
|
1883
|
+
torrent.on("wire", () => noteAfterRejoin(torrent, "peer"));
|
|
1884
|
+
torrent.on("download", () => noteAfterRejoin(torrent, "data"));
|
|
1885
|
+
|
|
1715
1886
|
torrent.on("warning", (warning) => {
|
|
1716
1887
|
logger.warn(`torrent-pool: [${label()}] warning: ${formatWarning(warning)}`);
|
|
1717
1888
|
});
|
|
@@ -519,17 +519,27 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
519
519
|
const ahead = secondsAhead(earliest(before, trackEnd), at);
|
|
520
520
|
constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false,
|
|
521
521
|
completion.sourceArrivalAt, completion.sourcePlanned);
|
|
522
|
-
|
|
522
|
+
const beyond = completion.segment.ranges.filter(({ start }) => compare(start, before) > 0);
|
|
523
|
+
if (compare(after, before) <= 0 && beyond.length > 0) {
|
|
523
524
|
// A timestamp hole cannot be repaired by waiting longer. Keep it
|
|
524
525
|
// distinct from an unavailable rate or a slow but finite service.
|
|
525
|
-
|
|
526
|
+
// Said with the track and both edges: the refusals of
|
|
527
|
+
// torrent-tv/meta#105 could not be told apart afterwards.
|
|
528
|
+
return { ...result(false, null, buffered, reserve, null, 'media-continuity-unavailable', preparedSegments),
|
|
529
|
+
discontinuity: { trackId: state.track.id ?? trackIndex, segmentIndex: completion.segment.index,
|
|
530
|
+
contiguousEndSeconds: toSeconds(before),
|
|
531
|
+
rangeStartSeconds: toSeconds(earliest(...beyond.map(({ start }) => start))) } };
|
|
526
532
|
}
|
|
527
533
|
}
|
|
528
534
|
if (compare(after, trackEnd) < 0) {
|
|
529
535
|
// Everything this track still needs has been delivered in the schedule
|
|
530
536
|
// and its media still stops short of the end: a hole between ranges
|
|
531
537
|
// already held, which no arrival crosses.
|
|
532
|
-
|
|
538
|
+
const next = delivered.find(({ start }) => compare(start, after) > 0);
|
|
539
|
+
return { ...result(false, null, buffered, reserve, null, 'media-continuity-unavailable', preparedSegments),
|
|
540
|
+
discontinuity: { trackId: state.track.id ?? trackIndex, segmentIndex: null,
|
|
541
|
+
contiguousEndSeconds: toSeconds(after), rangeStartSeconds: next ? toSeconds(next.start) : null,
|
|
542
|
+
trackEndSeconds: toSeconds(trackEnd) } };
|
|
533
543
|
}
|
|
534
544
|
for (const production of productions.filter((item) => item.trackIndex === trackIndex)) {
|
|
535
545
|
const before = contiguousEnd(prepared, at);
|