@torrent-tv/proxy 2.107.51 → 2.107.53

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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @file Decide when a second copy of a late missing block is useful.
3
+ *
4
+ * WebTorrent normally owns picking, queues and request reassignment. This
5
+ * exception is bounded to the currently required urgent pieces, a tail of at
6
+ * most 16 missing blocks, requests older than one wire queue, and two copies
7
+ * per block. The injected adapter is the only place that touches WebTorrent's
8
+ * private piece/reservation/request state.
9
+ */
10
+
11
+ /** `PIPELINE_MAX_DURATION`: WebTorrent sizes each wire queue to one second. */
12
+ const WIRE_QUEUE_MS = 1000;
13
+ const SHORT_TAIL_BLOCKS = 16;
14
+ const MAX_COPIES = 2;
15
+
16
+ export class TailRepeat {
17
+ #requiredPieces;
18
+ #adapter;
19
+ #schedule;
20
+ #wake;
21
+ #clear;
22
+ #scheduled = false;
23
+ #timer = null;
24
+ #timerAt = Number.POSITIVE_INFINITY;
25
+ #disposed = false;
26
+ #unwatch;
27
+ #log;
28
+ #now;
29
+ #reportedUnavailable = false;
30
+
31
+ /**
32
+ * @param {object} params
33
+ * @param {() => Array<{ from: number, to: number }>} params.requiredPieces
34
+ * @param {{ observe: Function, repeatBlock: Function, watch: Function }} params.adapter
35
+ * @param {(work: () => void) => void} [params.schedule]
36
+ * @param {(work: () => void, ms: number) => unknown} [params.wake]
37
+ * @param {(handle: unknown) => void} [params.clear]
38
+ * @param {(line: string) => void} [params.log]
39
+ * @param {() => number} [params.now]
40
+ */
41
+ constructor({ requiredPieces, adapter, schedule = setImmediate, wake = setTimeout, clear = clearTimeout, log = null, now = Date.now }) {
42
+ this.#requiredPieces = requiredPieces;
43
+ this.#adapter = adapter;
44
+ this.#schedule = schedule;
45
+ this.#wake = wake;
46
+ this.#clear = clear;
47
+ this.#log = log;
48
+ this.#now = now;
49
+ this.totals = { duplicated: 0, looks: 0, lateBlocks: 0 };
50
+ try {
51
+ this.#unwatch = adapter.watch(() => this.poke(), () => { this.totals.lateBlocks += 1; });
52
+ } catch {
53
+ this.#reportUnavailable();
54
+ this.#unwatch = null;
55
+ }
56
+ }
57
+
58
+ /** Look at the tails once this turn of the event loop is over. */
59
+ poke() {
60
+ if (this.#scheduled || this.#disposed) return;
61
+ this.#scheduled = true;
62
+ this.#schedule(() => this.#look());
63
+ }
64
+
65
+ /** Stop watching and cancel the one armed age check. */
66
+ dispose() {
67
+ this.#disposed = true;
68
+ this.#unwatch?.();
69
+ this.#unwatch = null;
70
+ if (this.#timer !== null) this.#clear(this.#timer);
71
+ this.#timer = null;
72
+ }
73
+
74
+ #lookAgainAt(at, now) {
75
+ if (!Number.isFinite(at) || at >= this.#timerAt || this.#disposed) return;
76
+ if (this.#timer !== null) this.#clear(this.#timer);
77
+ this.#timerAt = at;
78
+ this.#timer = this.#wake(() => {
79
+ this.#timer = null;
80
+ this.#timerAt = Number.POSITIVE_INFINITY;
81
+ this.poke();
82
+ }, Math.max(0, at - now));
83
+ this.#timer?.unref?.();
84
+ }
85
+
86
+ #look() {
87
+ this.#scheduled = false;
88
+ if (this.#disposed) return;
89
+ this.totals.looks += 1;
90
+ const now = this.#now();
91
+ let dueAt = Number.POSITIVE_INFINITY;
92
+ let unplaced = 0;
93
+ const asked = [];
94
+
95
+ for (const { from, to } of this.#requiredPieces()) {
96
+ for (let pieceIndex = from; pieceIndex <= to; pieceIndex += 1) {
97
+ if (this.#disposed) return;
98
+ let observation;
99
+ try {
100
+ observation = this.#adapter.observe(pieceIndex, SHORT_TAIL_BLOCKS);
101
+ } catch {
102
+ this.#reportUnavailable();
103
+ continue;
104
+ }
105
+ if (!observation?.available) {
106
+ this.#reportUnavailable();
107
+ continue;
108
+ }
109
+ if (observation.missingCount === 0 || observation.missingCount > SHORT_TAIL_BLOCKS) continue;
110
+
111
+ let pieceCopies = 0;
112
+ for (const block of observation.blocks) {
113
+ if (block.copies === 0 || block.copies >= MAX_COPIES) continue;
114
+ const firstAskedAt = block.firstAskedAt ?? Number.NEGATIVE_INFINITY;
115
+ if (now - firstAskedAt < WIRE_QUEUE_MS) {
116
+ dueAt = Math.min(dueAt, firstAskedAt + WIRE_QUEUE_MS);
117
+ continue;
118
+ }
119
+ let result;
120
+ try {
121
+ result = this.#adapter.repeatBlock(pieceIndex, block.index);
122
+ } catch {
123
+ this.#reportUnavailable();
124
+ continue;
125
+ }
126
+ if (result === "unavailable") {
127
+ this.#reportUnavailable();
128
+ continue;
129
+ }
130
+ if (result === "placed") {
131
+ pieceCopies += 1;
132
+ this.totals.duplicated += 1;
133
+ } else {
134
+ unplaced += 1;
135
+ }
136
+ }
137
+ if (pieceCopies > 0) asked.push(`${pieceIndex}:${pieceCopies}/${observation.missingCount}`);
138
+ }
139
+ }
140
+
141
+ if (asked.length > 0) {
142
+ this.#log?.(`tail: asked a second peer for the last blocks of piece:blocks/missing ${asked.join(" ")}; ` +
143
+ `${this.totals.duplicated} block copies asked so far, ${this.totals.lateBlocks} block(s) arrived after their piece was complete`);
144
+ }
145
+ if (unplaced > 0) dueAt = Math.min(dueAt, now + WIRE_QUEUE_MS);
146
+ this.#lookAgainAt(dueAt, now);
147
+ }
148
+
149
+ #reportUnavailable() {
150
+ if (this.#reportedUnavailable) return;
151
+ this.#reportedUnavailable = true;
152
+ this.#adapter.disable?.();
153
+ this.#log?.("tail: bounded repeat unavailable; using WebTorrent's ordinary picker");
154
+ }
155
+ }
@@ -25,14 +25,15 @@ import { deadlineOfMapZone, levelOfMapZone, priorityOfMapZone } from "./map-zone
25
25
  import { deriveSourceKey } from "../../utils/torrent-source-key.js";
26
26
  import { noteTorrentDestroyed } from "./destroyed-torrents.js";
27
27
  import { PieceTimings, describePieceTiming } from "./piece-timing.js";
28
- import { TailRepeat } from "./worker/tail-repeat.js";
28
+ import { TailRepeat } from "./download/TailRepeat.js";
29
+ import { WebTorrentTailRepeatAdapter } from "./worker/WebTorrentTailRepeatAdapter.js";
29
30
  import { tracePieceStorePut } from "./worker/trace-piece-store-put.js";
30
31
  import { beginSwarmLifecycleTrace, noteSwarmDiscovery, noteSwarmMetadata, noteSwarmPeer } from "./worker/trace-swarm-lifecycle.js";
31
32
 
32
33
  /**
33
34
  * The second-peer requests for the last blocks of a piece somebody waits for,
34
35
  * one per torrent, made when the torrent is first watched and stopped when it
35
- * is forgotten (`worker/tail-repeat.js`).
36
+ * is forgotten (`download/TailRepeat.js`).
36
37
  *
37
38
  * @type {WeakMap<object, TailRepeat>}
38
39
  */
@@ -1901,7 +1902,8 @@ export class TorrentPool {
1901
1902
  // per piece: a 2.3 GB film is 563.
1902
1903
  const timings = new PieceTimings();
1903
1904
  const waitTracePieceStarts = new Map();
1904
- tailRepeats.set(torrent, new TailRepeat({ torrent, requiredPieces: () => demandFor(torrent).selection.requiredPieces(),
1905
+ tailRepeats.set(torrent, new TailRepeat({ adapter: new WebTorrentTailRepeatAdapter(torrent),
1906
+ requiredPieces: () => demandFor(torrent).selection.requiredPieces(),
1905
1907
  log: (line) => logger.info(`download ${label()} ${line}`) }));
1906
1908
  torrent.on("wire", (wire) => {
1907
1909
  // WebTorrent asks a wire for a block by calling `request` and states
@@ -1922,7 +1924,7 @@ export class TorrentPool {
1922
1924
  trace("W8", "begin", { piece: index, startedAt, firstOffset: offset, blockLength: length });
1923
1925
  }
1924
1926
  // When this wire was asked for the block: how long it has held it is
1925
- // what decides whether a second peer is asked (`duplicateTailFor`).
1927
+ // what decides whether a second peer is asked (download/TailRepeat).
1926
1928
  this.requests.at(-1).askedAt = now;
1927
1929
  }
1928
1930
  return answer;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @file The narrow WebTorrent 2.8.5 adapter used by torrent/download/TailRepeat.
3
+ *
4
+ * This is the only live caller allowed to inspect piece buffers and wire
5
+ * queues, or to touch Piece.cancel/reserve and Torrent._request for bounded
6
+ * late-tail repetition. Keep library-private assumptions here and update the
7
+ * compatibility check when the pinned WebTorrent/torrent-piece pair changes.
8
+ */
9
+
10
+ const BLOCK_BYTES = 1 << 14;
11
+
12
+ function speedOf(wire) {
13
+ try {
14
+ const speed = wire?.downloadSpeed?.();
15
+ return Number.isFinite(speed) ? speed : 0;
16
+ } catch {
17
+ return 0;
18
+ }
19
+ }
20
+
21
+ function usableWires(torrent, pieceIndex) {
22
+ const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
23
+ return wires.filter((wire) => {
24
+ // WebTorrent's _request uses reserveRemaining/chunkLengthRemaining for a
25
+ // webseed, which requests the piece suffix rather than one bounded block.
26
+ if (!wire || wire.type === "webSeed" || wire.destroyed || wire.peerChoking) return false;
27
+ try {
28
+ return wire.peerPieces?.get?.(pieceIndex) === true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }).sort((left, right) => speedOf(right) - speedOf(left));
33
+ }
34
+
35
+ function requestCoversBlock(request, pieceIndex, blockIndex, chunks) {
36
+ if (request?.piece !== pieceIndex || !Number.isFinite(request.offset) || request.offset < 0) return false;
37
+ const firstBlock = Math.floor(request.offset / BLOCK_BYTES);
38
+ // A request without a length is an incompatible observation. Count it
39
+ // conservatively through the piece's end rather than allowing extra copies.
40
+ const lastBlock = Number.isFinite(request.length) && request.length > 0
41
+ ? Math.min(chunks - 1, Math.ceil((request.offset + request.length) / BLOCK_BYTES) - 1)
42
+ : chunks - 1;
43
+ return blockIndex >= firstBlock && blockIndex <= lastBlock;
44
+ }
45
+
46
+ /**
47
+ * Adapts a live WebTorrent Torrent to the small observation/action contract
48
+ * needed by TailRepeat. It does not choose which piece or block to repeat.
49
+ */
50
+ export class WebTorrentTailRepeatAdapter {
51
+ #torrent;
52
+ #disabled = false;
53
+
54
+ /** @param {object} torrent A WebTorrent Torrent instance. */
55
+ constructor(torrent) {
56
+ this.#torrent = torrent;
57
+ }
58
+
59
+ /** Disable the exception after an incompatible private API is observed. */
60
+ disable() {
61
+ this.#disabled = true;
62
+ }
63
+
64
+ /**
65
+ * Read missing blocks and their in-flight copy count/age.
66
+ *
67
+ * @param {number} pieceIndex
68
+ * @param {number} maxMissing Upper bound supplied by the download policy; a
69
+ * larger tail is reported by count without allocating a per-block list.
70
+ * @returns {{ available: boolean, missingCount: number, blocks: Array<{ index: number, copies: number, firstAskedAt: number|null }> }}
71
+ */
72
+ observe(pieceIndex, maxMissing) {
73
+ const torrent = this.#torrent;
74
+ const piece = torrent?.pieces?.[pieceIndex];
75
+ // Completed pieces are removed from Torrent.pieces; a piece with no
76
+ // allocated block buffer has no request in flight, so neither is a tail.
77
+ if (!piece) return { available: true, missingCount: 0, blocks: [] };
78
+ const buffer = Array.isArray(piece?._buffer) ? piece._buffer : null;
79
+ if (!buffer) return { available: true, missingCount: 0, blocks: [] };
80
+ if (this.#disabled || typeof piece.cancel !== "function" || typeof torrent?._request !== "function") {
81
+ this.#disabled = true;
82
+ return { available: false, missingCount: 0, blocks: [] };
83
+ }
84
+
85
+ const chunks = Number.isFinite(piece._chunks) ? piece._chunks : buffer.length;
86
+ let missingCount = Number.isFinite(piece._buffered) ? chunks - piece._buffered : 0;
87
+ if (!Number.isFinite(piece._buffered)) {
88
+ for (let index = 0; index < chunks; index += 1) if (!buffer[index]) missingCount += 1;
89
+ }
90
+ if (missingCount === 0 || missingCount > maxMissing) {
91
+ return { available: true, missingCount, blocks: [] };
92
+ }
93
+
94
+ const blocks = [];
95
+ for (let index = 0; index < chunks; index += 1) {
96
+ if (buffer[index]) continue;
97
+ blocks.push({ index, copies: 0, firstAskedAt: null });
98
+ }
99
+ for (const wire of Array.isArray(torrent.wires) ? torrent.wires : []) {
100
+ for (const request of Array.isArray(wire?.requests) ? wire.requests : []) {
101
+ for (const block of blocks) {
102
+ if (!requestCoversBlock(request, pieceIndex, block.index, chunks)) continue;
103
+ block.copies += 1;
104
+ const askedAt = Number.isFinite(request.askedAt) ? request.askedAt : Number.NEGATIVE_INFINITY;
105
+ block.firstAskedAt = block.firstAskedAt === null ? askedAt : Math.min(block.firstAskedAt, askedAt);
106
+ }
107
+ }
108
+ }
109
+ return { available: true, missingCount, blocks };
110
+ }
111
+
112
+ /**
113
+ * Try one repeat for an already-selected, already-aged missing block.
114
+ * Returns a status instead of throwing so an incompatible private API cannot
115
+ * stop ordinary downloading. Every refusal restores the reservation.
116
+ *
117
+ * @param {number} pieceIndex
118
+ * @param {number} blockIndex
119
+ * @returns {"placed"|"refused"|"unavailable"}
120
+ */
121
+ repeatBlock(pieceIndex, blockIndex) {
122
+ const torrent = this.#torrent;
123
+ const piece = torrent?.pieces?.[pieceIndex];
124
+ const buffer = Array.isArray(piece?._buffer) ? piece._buffer : null;
125
+ if (this.#disabled || !piece || !buffer || buffer[blockIndex] || typeof piece.cancel !== "function" ||
126
+ typeof piece.reserve !== "function" || typeof torrent?._request !== "function") {
127
+ return "unavailable";
128
+ }
129
+
130
+ const candidates = usableWires(torrent, pieceIndex);
131
+ const chunks = Number.isFinite(piece._chunks) ? piece._chunks : buffer.length;
132
+ if (candidates.length === 0) return "refused";
133
+
134
+ for (const wire of candidates) {
135
+ if ((wire.requests ?? []).some((request) => requestCoversBlock(request, pieceIndex, blockIndex, chunks))) continue;
136
+
137
+ try {
138
+ piece.cancel(blockIndex);
139
+ } catch {
140
+ this.#disabled = true;
141
+ return "unavailable";
142
+ }
143
+
144
+ let placed = false;
145
+ try {
146
+ // false prevents WebTorrent's hotswap from freeing a third reservation.
147
+ placed = torrent._request(wire, pieceIndex, false) === true;
148
+ } catch {
149
+ // A thrown private call may have queued the request before failing.
150
+ placed = (wire.requests ?? []).some((request) => request?.piece === pieceIndex &&
151
+ Math.floor(request.offset / BLOCK_BYTES) === blockIndex);
152
+ if (!placed) {
153
+ try {
154
+ if (piece.reserve() !== blockIndex) this.#disabled = true;
155
+ } catch { this.#disabled = true; }
156
+ }
157
+ this.#disabled = true;
158
+ return placed ? "placed" : "unavailable";
159
+ }
160
+ if (placed) return "placed";
161
+
162
+ // _request refused before reserving. Claim the freed block back so the
163
+ // normal picker cannot see an unaccounted reservation.
164
+ try {
165
+ if (piece.reserve() !== blockIndex) {
166
+ this.#disabled = true;
167
+ return "unavailable";
168
+ }
169
+ } catch {
170
+ this.#disabled = true;
171
+ return "unavailable";
172
+ }
173
+ }
174
+ return "refused";
175
+ }
176
+
177
+ /** Subscribe to changes that can alter tail observations. */
178
+ watch(onChange, onLateBlock = () => {}) {
179
+ const torrent = this.#torrent;
180
+ const watched = new Map();
181
+ const poke = () => onChange();
182
+ const forget = (wire) => {
183
+ const listeners = watched.get(wire);
184
+ if (!listeners) return;
185
+ wire.removeListener?.("piece", listeners.onBlock);
186
+ for (const event of ["unchoke", "have", "bitfield"]) wire.removeListener?.(event, listeners.onPeer);
187
+ wire.removeListener?.("close", listeners.onClose);
188
+ watched.delete(wire);
189
+ };
190
+ const watchWire = (wire) => {
191
+ if (!wire || watched.has(wire) || typeof wire.on !== "function") return;
192
+ const listeners = {
193
+ onBlock: (index) => {
194
+ if (Number.isInteger(index) && torrent.bitfield?.get?.(index)) onLateBlock();
195
+ poke();
196
+ },
197
+ onPeer: poke,
198
+ onClose: () => { forget(wire); poke(); }
199
+ };
200
+ watched.set(wire, listeners);
201
+ wire.on("piece", listeners.onBlock);
202
+ for (const event of ["unchoke", "have", "bitfield"]) wire.on(event, listeners.onPeer);
203
+ wire.once?.("close", listeners.onClose);
204
+ poke();
205
+ };
206
+ const onWire = watchWire;
207
+ torrent.on?.("wire", onWire);
208
+ torrent.on?.("verified", poke);
209
+ for (const wire of Array.isArray(torrent.wires) ? torrent.wires : []) watchWire(wire);
210
+ return () => {
211
+ torrent.removeListener?.("wire", onWire);
212
+ torrent.removeListener?.("verified", poke);
213
+ for (const wire of [...watched.keys()]) forget(wire);
214
+ };
215
+ }
216
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Cached piece geometry for playback forecasts. It is keyed by a live output
3
+ * object and explicitly forgotten when that output is disposed.
4
+ */
5
+ export class OutputForecastGeometry {
6
+ #byOutput = new Map();
7
+ #host;
8
+
9
+ constructor(host) {
10
+ this.#host = host;
11
+ }
12
+
13
+ get(output) {
14
+ return this.#byOutput.get(output) ?? null;
15
+ }
16
+
17
+ forget(output) {
18
+ return this.#byOutput.delete(output);
19
+ }
20
+
21
+ size() {
22
+ return this.#byOutput.size;
23
+ }
24
+
25
+ segmentsOf(output, sessionInit, inventory, positionSeconds) {
26
+ const timeline = output.timeline;
27
+ const count = timeline?.segmentCount ?? 0;
28
+ let forecast = this.#byOutput.get(output);
29
+ if (!forecast) {
30
+ forecast = { pieces: [], geometry: {} };
31
+ this.#byOutput.set(output, forecast);
32
+ }
33
+ const kept = forecast.pieces;
34
+ const segments = [];
35
+ const lookahead = Number(this.#host.lookaheadSeconds);
36
+ const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
37
+ const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
38
+ let low = 0, high = count;
39
+ while (low < high) {
40
+ const middle = (low + high) >>> 1;
41
+ if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
42
+ else high = middle;
43
+ }
44
+ const first = Math.max(0, low - 1);
45
+ low = first;
46
+ high = count;
47
+ while (low < high) {
48
+ const middle = (low + high) >>> 1;
49
+ if (timeline.publishedStartOf(middle) < end) low = middle + 1;
50
+ else high = middle;
51
+ }
52
+ const last = low;
53
+ const indexes = new Set([0]);
54
+ for (let index = first; index < last; index++) indexes.add(index);
55
+ for (const index of indexes) {
56
+ const endSeconds = timeline.publishedStartOf(index + 1);
57
+ const ranges = inventory.has(index) ? this.#host.finishedMediaRangesOf(output, index) : undefined;
58
+ const mediaRanges = ranges && output.segmentFormat?.servedMediaRanges ?
59
+ output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined;
60
+ if (index !== 0 && mediaRanges === undefined && !inventory.has(index) &&
61
+ Number.isFinite(endSeconds) && Number.isFinite(positionSeconds) &&
62
+ !this.#host.endsWithinPlayerSeekTolerance(endSeconds, positionSeconds) &&
63
+ endSeconds < positionSeconds) {
64
+ continue;
65
+ }
66
+ const startSeconds = timeline.publishedStartOf(index);
67
+ const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
68
+ const previous = kept[index];
69
+ if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
70
+ previous.mediaRanges === mediaRanges && sameInputs(previous.sourceInputs, sourceInputs)) {
71
+ segments.push(previous);
72
+ continue;
73
+ }
74
+ const segment = { index, startSeconds, endSeconds, sourceInputs, mediaRanges };
75
+ kept[index] = segment;
76
+ segments.push(segment);
77
+ }
78
+ if (kept.length > count) kept.length = count;
79
+ return segments;
80
+ }
81
+ }
82
+
83
+ function sameInputs(left, right) {
84
+ if (left === right) return true;
85
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
86
+ return left.every((input, index) => input.sourceId === right[index].sourceId && input.ranges === right[index].ranges);
87
+ }